diff --git a/.gitattributes b/.gitattributes index 0871343..16ab2c1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -17,6 +17,3 @@ *.gif binary *.woff binary *.woff2 binary - -# Merge strategy for CHANGELOG -CHANGELOG.md merge=union diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 5546c73..32484d8 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,20 +1,20 @@ -## Related Issues +## Related issues Closes # ## Description - + -## Steps to Test +## How to test - + 1. ## Checklist -- [ ] I have tested this change manually -- [ ] I have added or updated tests as appropriate +- [ ] I tested this change by hand +- [ ] I added or updated tests where needed - [ ] All checks pass (`npm test`, `npm run lint`, `npm run format:check`, `npm run typecheck --workspaces`) -- [ ] I have updated documentation as needed +- [ ] I updated the documentation where needed diff --git a/.gitignore b/.gitignore index f7205ec..b8d341c 100644 --- a/.gitignore +++ b/.gitignore @@ -42,7 +42,6 @@ Thumbs.db # VS Code extension packaging *.vsix packages/extension/LICENSE -packages/extension/CHANGELOG.md # Environment .env @@ -65,3 +64,6 @@ npm-debug.log* packages/**/test/*.js packages/**/test/*.js.map packages/**/test/*.d.ts +packages/cli/LICENSE +packages/language/LICENSE +packages/transform/LICENSE diff --git a/.nvmrc b/.nvmrc index 60ade1a..a45fd52 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -24.19.0 +24 diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 204e61e..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,5 +0,0 @@ -# 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). - -Nothing has been released yet. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 707ccb5..c3d6ebd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,19 +1,16 @@ # Contributing to BPMNscript > 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. +> Pull requests are accepted after the thesis is submitted, and the guidelines below apply from then on. ## Development setup ### Prerequisites -- [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) - `.nvmrc` pins `24.19.0` because 24.20.0 and 24.21.0 fail `langium generate` with `ERR_INVALID_URL`; move it back to `24` once a later 24.x generates cleanly. -- [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/)) +- [Node.js](https://nodejs.org/) 22 or newer (`.nvmrc` pins Node 24, and `nvm use` reads it) +- [VS Code](https://code.visualstudio.com/) with the recommended extensions, which VS Code offers on first open +- [Docker](https://www.docker.com/) for the end-to-end tests ([Podman](https://podman.io/) or [Rancher Desktop](https://rancherdesktop.io/) work too) ### Getting started @@ -25,9 +22,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, compiles TypeScript across all packages, and bundles the extension. +`npm test` runs the full suite, including end-to-end tests that deploy to an Operaton engine in Docker. +To skip those, for example without Docker or while iterating on unit tests: ```sh SKIP_DOCKER_TESTS=true npm test @@ -36,31 +33,21 @@ SKIP_DOCKER_TESTS=true npm test ### Trying it out in VS Code After building, press F5 in VS Code. -This opens a second VS Code window with the BPMNscript extension loaded. -In that window you can create or open `.bpmnscript` files and get syntax highlighting, inline diagnostics, autocompletion, hover, and rename. -To compile or decompile a file, use the command palette (`BPMNscript: Compile to BPMN` / `BPMNscript: Decompile to BPMNscript`) or the "Convert" panel in the BPMNscript activity-bar sidebar. -The CLI alternative (`npx bpmns build `) still works if you prefer the terminal. -For a normal install instead of the development host, see [packages/extension/README.md](packages/extension/README.md#installing). +A second window opens with the extension loaded, where you can open `.bpmnscript` files and convert them from the command palette or the Convert panel in the BPMNscript sidebar. +[packages/extension/README.md](packages/extension/README.md) describes what the extension does and how to install it. ### 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 is `packages/language/src/bpmn-script.langium`. +After editing it, regenerate the AST types from the repo root so TypeScript sees the change: ```sh -npm run langium:generate +npm run langium:generate # once +npm run langium:watch # on every save ``` -For continuous regeneration while editing: - -```sh -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. +The generated code lands in `src/generated/`, which is git-ignored, so there is nothing to commit. +CI runs `git diff --exit-code` after `npm run build` and fails if the build changed any tracked file. ### Running tests @@ -73,26 +60,49 @@ npm test --workspace packages/extension # E FUZZ_N=2000 FUZZ_OUT=/tmp/fuzz npm test --workspace tests -- fuzz.test.ts # Round-trip fuzz over 2000 generated programs ``` -`npm test` runs the round-trip fuzz over the first 200 generated programs as part of the tests workspace. -The extension tests require `@bpmn-script/language` and `@bpmn-script/transform` to be built first (they are consumed as compiled `out/` directories). -Run `npm run build` from the repo root before running the extension suite in isolation. +`npm test` includes the round-trip fuzz over the first 200 generated programs. +The extension tests use the compiled `out/` directories of `@bpmn-script/language` and `@bpmn-script/transform`, so run `npm run build` before running that suite on its own. ### 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: +[Prettier](https://prettier.io/) formats and [ESLint](https://eslint.org/) lints, 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 typecheck --workspaces # Typecheck test sources; vitest does not (used in CI) +npm run format # Format all source files +npm run format:check # Check formatting without writing (CI) +npm run lint # Run ESLint (does not check formatting) +npm run typecheck --workspaces # Typecheck the test files, which vitest does not (CI) +``` + +VS Code with the recommended Prettier extension formats on save, and `.editorconfig` covers indentation and line endings for other editors. + +### Diagnostic messages + +Every message a user sees from the compiler, the editor, or the CLI has the same shape. + +- The first line is a short headline that says what is wrong and where, and stands on its own. + It never says how to fix it. +- The headline starts in lowercase, unless it opens with a quoted name, and has no final period. +- Optional lines follow, each starting with `note: ` for context (why it matters, what the engine does) or `help: ` for a concrete fix the user can make. + Each line holds one fact, in lowercase after the prefix and without a final period. + A line is never a question. +- No semicolons anywhere in a message, and no "the following" or "as shown". +- Use plain words that are still technically exact. + Name an engine class or method only when it explains the cause, and then in a `note:` line. +- Put code and names in single quotes. + +``` +'emit message Notify' sends nothing without a 'class', 'expression', 'delegate', or 'topic' +``` + +``` +the condition must be boolean, but it is a string +note: the engine throws 'condition expression returns non-Boolean' when it evaluates it ``` -Formatting happens on save if you use VS Code with the recommended Prettier extension. -The `.editorconfig` handles basics (indentation, line endings) for other editors. +The language package builds these with `diagnosticText` in `packages/language/src/vocabulary.ts`. -## Guidelines (post-thesis) +## Guidelines ### Reporting bugs @@ -104,12 +114,11 @@ Use the [feature request template](https://github.com/BPMNscript/bpmnscript/issu ### Pull requests -- Reference the related issue with `Closes #...` -- Include a brief description of what changed and why -- 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 +- Reference the related issue with `Closes #...`. +- Describe what changed and why. +- Make sure `npm test`, `npm run lint`, `npm run format:check`, and `npm run typecheck --workspaces` all pass. ### 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. +Technical decisions are recorded as [Markdown ADRs](docs/decisions/) using [MADR 4.0.0](https://adr.github.io/madr/). +If your change makes an architectural decision, add an ADR for it. diff --git a/README.md b/README.md index 08442b0..7a0a73d 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,19 @@ # BPMNscript -Write executable business processes as text, compile to BPMN 2.0 XML, and read existing BPMN files back as text. +Write executable business processes as text, compile them to BPMN 2.0 XML, and read existing BPMN files back as text. -> **Status:** a bachelor's thesis project, pre-release. -> It is not published to npm or the VS Code Marketplace yet, so everything below builds from source. +> Status: version 0.1.0 is the first release, from a bachelor's thesis project. +> Download the `.vsix` of the VS Code extension from the GitHub Release, or build everything from source as described below. +> The packages are not published to npm or the VS Code Marketplace. ## What it is BPMN 2.0 is the standard for describing a business workflow as a diagram of tasks, gateways, and events. -A process engine such as [Operaton](https://operaton.org/) loads that diagram and runs it: assigning the human steps to people, calling the automated ones, waiting on timers and incoming messages. -The diagram is stored as XML, and the XML is what actually gets deployed. +A process engine such as [Operaton](https://operaton.org/) loads that diagram and runs it: it assigns the human steps to people, calls the automated ones, and waits on timers and incoming messages. +The diagram is stored as XML, and the XML is what gets deployed. Usually you draw it in a graphical modeler. -BPMNscript gives BPMN a textual representation: +BPMNscript gives BPMN a textual form instead: ```bpmnscript process invoice-approval { @@ -31,15 +32,15 @@ process invoice-approval { } ``` -Compiling this gives you a valid BPMN 2.0 file that a graphical modeler opens as a diagram and Operaton deploys. +Compiling this gives you a 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 fit into their existing code workflow: git and diffs, autocompletion, and compile-time error checking. +Developers who'd rather work in a text editor than on a diagram canvas, and who want processes to fit their existing code workflow: git and diffs, autocompletion, and compile-time error checking. 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. +The diagram is generated for you, so there are no lines to route and no layout to redo after a change. ## Installing @@ -53,30 +54,12 @@ npm run build ``` `npm run build` generates the parser from the grammar, compiles every package, and bundles the VS Code extension. - -To check it worked: - -```sh -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 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 +To check it worked, run `npm test`. +The suite includes end-to-end tests that boot an Operaton engine in Docker, so it needs a running Docker daemon; set `SKIP_DOCKER_TESTS=true` to skip them ([CONTRIBUTING.md](CONTRIBUTING.md#getting-started)). ## Using the CLI -The `bpmns` command has two subcommands. -After a build, run it with `npx`: +After a build, run the `bpmns` command with `npx`: ```sh # Compile .bpmnscript to BPMN 2.0 XML, next to the source file @@ -86,179 +69,57 @@ npx bpmns build examples/spring-boot/processes/invoice-approval.bpmnscript cd examples/spring-boot/processes npx bpmns build invoice-approval.bpmnscript -o out/invoice-approval.bpmn -# Decompile BPMN XML back to the DSL +# Decompile BPMN XML back to BPMNscript npx bpmns parse out/invoice-approval.bpmn -o out/invoice-approval.bpmnscript ``` -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. +Warnings go to stderr and still produce a file. +A validation error on `build` or a BPMN construct the language can't express on `parse` exits `1` and writes nothing. +[packages/cli/README.md](packages/cli/README.md) has the exit codes and the rules for overwriting files. ## 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 (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 +- syntax highlighting, including the fenced body of a `script` task in its own language (JavaScript, Groovy, Python and Ruby) +- completion, hover, go to definition, find all references, rename, and an outline +- errors and warnings inline as you type, from the same validator `bpmns build` runs +- a Convert panel in the sidebar that compiles the open file, opens its counterpart when one exists, or decompiles a `.bpmn` picked from disk -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. +Compile and decompile are also in the command palette, under "BPMNscript". +To install the extension for everyday use, see [Installing](packages/extension/README.md#installing) in the extension README. ## The language -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. -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. +A file holds one `process` block. +Steps run top to bottom, so you never write a sequence flow; control flow uses structured statements (`if`, `while`, `parallel`, `goto`), and events read like try/catch with `on`, `throw` and `emit`. +The [language reference](packages/language/README.md) lists every statement, the BPMN element it compiles to, and the settings it takes. -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. +[examples/spring-boot/processes/](examples/spring-boot/processes/) holds the example processes, each covering one construct or combination; [examples/README.md](examples/README.md) says which. -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. +## Round-tripping BPMN XML to BPMNscript -### Events +BPMN is a much larger language than BPMNscript, so a `.bpmn` file can hold more than a script has a form for. +A decompile never changes a process without saying so ([ADR-0012](docs/decisions/0012-honest-bpmn-import.md)): -The event layer reads like try/catch. -A handler written at the end of a body catches an event raised anywhere inside it, `throw` ends the current path the way `throw` does in Java, and `emit` fires the event and carries on. -`emit link` is the one emit that ends the path instead: the token continues at the `await link` of the same name, which is how a diagram's off-page connector reads ([packages/language/README.md](packages/language/README.md#link-events)). -Every trigger kind but `cancel` and `link` opens such a handler, `cancel` being caught on the block it gives up and `link` by its `await`; the table in [packages/language/README.md](packages/language/README.md#the-event-layer) gives each kind with its payload. -A message, a signal, a timer, or a condition can also start a process, carrying the same payload it does as a handler, and a `terminate` end stops every running path at once. -An error or escalation code is declared in the process header, beside the `var` declarations, and named at every throw, emit, and catch site, and in an external task's `error when ` mapping. +- A construct the language can't express, or one Operaton refuses to deploy, is refused with an error and nothing is written. +- Content BPMNscript doesn't carry, such as a lane, a pool or a text annotation, is dropped with a warning naming each item. +- A construct imported in a changed form gets a warning that says whether the engine runs it differently. +- A part of the graph the printer can't fit into a block becomes a `goto`, or a `// unstructured region: hand-repair required` comment where no jump fits, and is warned about. -A handler can also attach to one step instead of the whole body, which is where `on Host: kind` comes in. -This process reviews an order, takes payment in a sub-process, and hangs four different escapes off those two activities: - -```bpmnscript -process order-handling { - error PAYMENT_DECLINED(message: "The card could not be charged") - escalation LARGE_PAYMENT_FLAGGED - - start OrderPlaced - user ReviewOrder(label: "Review order", assignee: "demo") - - subprocess Payment(label: "Process payment") { - service AuthorizePayment(label: "Authorize payment", class: "com.example.demo.LogDelegate") - emit escalation(LARGE_PAYMENT_FLAGGED) - service CapturePayment(label: "Capture payment", class: "com.example.demo.LogDelegate") - } - - service ShipOrder(label: "Ship order", class: "com.example.demo.LogDelegate") - end OrderShipped - - on ReviewOrder: timer("PT4H", alongside) { - service SendReviewReminder(label: "Send a review reminder", class: "com.example.demo.LogDelegate") - } - - on ReviewOrder: message("AutoApproved") { - service MarkAutoApproved(label: "Mark the order auto-approved", class: "com.example.demo.LogDelegate") - goto Payment - } - - on Payment: error(PAYMENT_DECLINED, code: c, message: m) { - user ContactCustomer(label: "Contact the customer about the decline", assignee: "demo") - } - - on Payment: escalation(LARGE_PAYMENT_FLAGGED, alongside) { - user ReviewLargePayment(label: "Review the large payment", assignee: "manager") - } -} -``` - -`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, and the large-payment escalation pulls in a manager without stopping the capture. - -### Compensation - -A `subprocess` can carry an `on compensation` block: the steps that undo whatever it already did. -When something later fails, the completed units unwind newest first. - -```bpmnscript -process booking-saga { - error BOOKING_FAILED(message: "A booking step could not be completed") - - start TripRequested - user ReviewTrip(label: "Review the trip", assignee: "demo") - - subprocess BookFlight(label: "Book the flight") { - service ReserveSeat(label: "Reserve the seat", class: "com.example.demo.LogDelegate") - - on compensation { - service ReleaseSeat(label: "Release the seat", class: "com.example.demo.LogDelegate") - } - } - - subprocess BookHotel(label: "Book the hotel") { - service ReserveRoom(label: "Reserve the room", class: "com.example.demo.LogDelegate") - - on compensation { - service ReleaseRoom(label: "Release the room", class: "com.example.demo.LogDelegate") - } - } - - service ConfirmTrip(label: "Confirm the trip", class: "com.example.demo.LogDelegate") - end TripConfirmed - - on error(BOOKING_FAILED) { - emit compensation Undo - user NotifyTraveler(label: "Notify the traveler", assignee: "demo") - } -} -``` - -Book the hotel after the flight, then fail: the seat gets released, and the traveler gets told. - -The invoice-approval and order-handling programs above are files from [examples/spring-boot/processes/](examples/spring-boot/processes/), the second with one boundary handler left out; the compensation one is written for this page, and `compensating-saga.bpmnscript` is its deployable counterpart. -The end-to-end suite deploys several of that directory's processes to an Operaton engine and asserts what the engine does with them. - -## Roundtripping BPMN XML -> BPMNscript - -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 (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 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. - -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, which the engine reads off the step alone. - -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. +Engine settings a modeler tunes on a step, such as async continuations, listeners, input/output parameters and forms, import without a warning. +The diagram's coordinates are discarded, since the layout is generated again on every compile. +[packages/transform/README.md](packages/transform/README.md#the-import-contract) lists every refusal and warning, and [docs/bpmn-coverage.md](docs/bpmn-coverage.md) shows what happens to each BPMN element. ## Running a process on a real engine -[examples/spring-boot/](examples/spring-boot/) is a working Operaton 2.1.0 + Spring Boot deployment with Java delegates for the service tasks. -Its [README](examples/spring-boot/README.md) walks through compiling the processes, starting the engine, opening Cockpit and Tasklist, and stepping through a loan approval example process by hand. +[examples/spring-boot/](examples/spring-boot/) is an Operaton 2.1.0 and Spring Boot deployment with Java delegates for the service tasks. +Its [README](examples/spring-boot/README.md) walks through compiling the processes, starting the engine, and stepping through a loan approval in Cockpit and Tasklist. ## Architecture -Both directions route through a shared intermediate representation. -Each transform only has to know how to convert to or from the IR, never to every other format directly. +Both directions go through a shared intermediate representation (IR), so each transform converts to or from the IR and never directly between file formats. ```mermaid flowchart LR @@ -274,54 +135,52 @@ 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 in the engine's own terms, tied to no file format), and written out from there. -Compiling is `.bpmnscript` -> AST -> IR -> `.bpmn`; decompiling is `.bpmn` -> IR -> `.bpmnscript`. - -The IR carries Operaton's semantics under names with no vendor prefix: the engine's bindings, execution settings, input/output parameters and lifecycle listeners are all plain-named IR fields, and `operaton:` is applied only where `irToXml` builds the moddle element from a local [moddle extension](packages/transform/src/operaton-moddle.json) ([ADR-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)). +The IR is a small set of plain TypeScript objects that describe one process in the engine's terms, tied to no file format ([ADR-0007](docs/decisions/0007-intermediate-representation-ast-bpmn.md)). +[packages/transform/README.md](packages/transform/README.md) describes it and the four transforms. -| Library | Role | -| --------------------------------------------------------------- | --------------------------------------------------- | -| [Langium](https://langium.org/) | Grammar, parser, AST, LSP server, VS Code extension | -| [bpmn-moddle](https://github.com/bpmn-io/bpmn-moddle) | BPMN 2.0 XML reading and writing | -| [bpmn-auto-layout](https://github.com/bpmn-io/bpmn-auto-layout) | Generates diagram layout data on export | +| Library | Role | +| --------------------------------------------------------------- | --------------------------------------- | +| [Langium](https://langium.org/) | Grammar, parser, AST, LSP server | +| [bpmn-moddle](https://github.com/bpmn-io/bpmn-moddle) | BPMN 2.0 XML reading and writing | +| [bpmn-auto-layout](https://github.com/bpmn-io/bpmn-auto-layout) | Generates diagram layout data on export | ## Repository structure ```text packages/ language/ Langium grammar, AST, validator, language server - transform/ IR types and bidirectional transforms (AST/IR/XML/DSL) + transform/ IR types and the four transforms (AST/IR/XML/DSL) cli/ bpmns build / parse commands - extension/ VS Code extension: language server, compile/decompile commands, sidebar + extension/ VS Code extension: language server, conversion commands, sidebar tests/ Round-trip, fixture, and end-to-end tests examples/ spring-boot/ Operaton + Spring Boot deployment, also the e2e fixture docs/ - decisions/ Architectural decision records - glossary.md Shared vocabulary + decisions/ Architectural decision records + bpmn-coverage.md BPMN elements and what happens to each + 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)). +- One executable process per file. + A collaboration imports as its one executable process, with a warning per pool and per message flow, and a second executable process refuses the import. +- Lanes, data objects, data stores, text annotations and groups have no textual form and are dropped with a warning. + Process variables are declared with `var` and reach a step through `input` and `output` parameters. +- An ad hoc sub-process and a complex gateway refuse the import. +- A standard loop is dropped with a warning, so the activity runs once. +- A compensation handler is written as an undo block inside a `subprocess`, so a compensation boundary event refuses the import and the message names the rewrite. +- A `decide` step names a deployed decision table; the table itself is written outside BPMNscript. +- Hand-placed diagram coordinates are discarded on import without a warning ([ADR-0009](docs/decisions/0009-auto-layout-and-expansion-hint.md)). + +[docs/bpmn-coverage.md](docs/bpmn-coverage.md) has the full list. ## 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. The thesis asks whether a textual DSL can be a practical alternative to graphical BPMN editors for developers who prefer working in code. -Design decisions are written up as [Markdown ADRs](docs/decisions/) using [MADR 4.0.0](https://adr.github.io/madr/); they're the best place to find out why something is the way it is. -Shared vocabulary is collected in [docs/glossary.md](docs/glossary.md). +Design decisions are written up as [Markdown ADRs](docs/decisions/) using [MADR 4.0.0](https://adr.github.io/madr/), and terms are collected in the [glossary](docs/glossary.md). ## Contributing diff --git a/docs/bpmn-coverage.md b/docs/bpmn-coverage.md index 387c992..130e5d9 100644 --- a/docs/bpmn-coverage.md +++ b/docs/bpmn-coverage.md @@ -4,29 +4,31 @@ Which BPMN 2.0 elements BPMNscript writes, and what the importer does with each 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. +An element's `id` is its name in the script, written plainly where the `ID` terminal spells it and in backticks otherwise (`` user `Task.1` ``), so every id a name slot carries survives import and rebuild ([ADR-0010](decisions/0010-deterministic-synthesized-ids.md)). +A file holding an id outside ASCII letters, digits, `_`, `-` and `.`, starting with a letter or `_`, is refused (`UnsupportedDocumentError`). ## 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 | +| 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 an `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`), or a mapping whose `source` or `target` names a variable no plain name spells, such as `var` (`UnsupportedVariableNameError`) | +| `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 @@ -118,6 +120,7 @@ Operaton's own extension elements (`operaton:` settings, input/output parameters | `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 | +| no `default` where every route out is conditioned | `else fail`, closing the `if` chain or the `parallel` block | imported; prints as `else fail`, so the engine still stops the run when no condition holds | | `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`) | @@ -133,25 +136,25 @@ Operaton's own extension elements (`operaton:` settings, input/output parameters ## 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`) | +| 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 under the root's `name`; 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 | +| 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, and `conditionDocumentation`, `timerDocumentation`, `countDocumentation` or `eachDocumentation` beside the value it documents | imported at every position that reads a `name`, inside a condition, a time, a repetition count, a completion condition, and a named `bpmn:inputDataItem`; warned (`documentation`) on an event handler, a typed event, a sequence flow, an event definition, a repetition, an unnamed `bpmn:inputDataItem`, 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/0001-use-apache-2-license.md b/docs/decisions/0001-use-apache-2-license.md index 4d57583..6533eb5 100644 --- a/docs/decisions/0001-use-apache-2-license.md +++ b/docs/decisions/0001-use-apache-2-license.md @@ -4,7 +4,7 @@ date: 2026-04-13 decision-makers: Marlon Kranz --- -# Use Apache 2.0 License +# Use the Apache 2.0 license ## Context and Problem Statement @@ -32,4 +32,4 @@ It is compatible with both MIT and EPL-2.0 dependencies. - Good, because Apache 2.0 includes an explicit patent grant, protecting contributors and users - Good, because it is compatible with the licenses of all key dependencies -- Neutral, because Apache 2.0 requires a NOTICE file for derivative works, adding slight administrative overhead +- Neutral, because Apache 2.0 section 4(d) requires redistributors to keep a NOTICE file only if the work includes one, and this project ships none diff --git a/docs/decisions/0002-use-mono-repo-structure.md b/docs/decisions/0002-use-mono-repo-structure.md index 6f4233d..76fa183 100644 --- a/docs/decisions/0002-use-mono-repo-structure.md +++ b/docs/decisions/0002-use-mono-repo-structure.md @@ -4,11 +4,11 @@ date: 2026-04-13 decision-makers: Marlon Kranz --- -# Use Mono-repo Structure +# Use a mono-repo structure ## Context and Problem Statement -BPMNscript consists of multiple components: the core language (grammar, parser, AST, validation), a CLI tool, and a VS Code extension. +BPMNscript consists of multiple components: the core language (grammar, parser, AST, validation), the transform between the language and BPMN XML, a CLI tool, and a VS Code extension. Should these components live in a single repository or be split across multiple repositories? ## Decision Drivers @@ -20,7 +20,7 @@ Should these components live in a single repository or be split across multiple ## Considered Options - Mono-repo with npm workspaces -- Multi-repo (separate repos for core, CLI, VS Code extension) +- Multi-repo (separate repos for the language, the transform, the CLI and the VS Code extension) ## Decision Outcome diff --git a/docs/decisions/0003-use-langium-as-language-workbench.md b/docs/decisions/0003-use-langium-as-language-workbench.md index 02294a0..243868e 100644 --- a/docs/decisions/0003-use-langium-as-language-workbench.md +++ b/docs/decisions/0003-use-langium-as-language-workbench.md @@ -4,7 +4,7 @@ date: 2026-04-13 decision-makers: Marlon Kranz --- -# Use Langium as Language Workbench +# Use Langium as the language workbench ## Context and Problem Statement @@ -31,7 +31,7 @@ Which language workbench should be used to implement the DSL infrastructure? ## 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. +Chosen option: "Langium", primarily for its own ergonomics: a smaller, more approachable toolchain than Xtext's, and native access to the existing JavaScript 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. @@ -41,7 +41,7 @@ Browser deployment for a future playground was a secondary factor, all within a - 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 `bpmn-moddle` (the standard JavaScript 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 diff --git a/docs/decisions/0004-use-vscode-as-primary-ide-target.md b/docs/decisions/0004-use-vscode-as-primary-ide-target.md index ef7911d..04d90bb 100644 --- a/docs/decisions/0004-use-vscode-as-primary-ide-target.md +++ b/docs/decisions/0004-use-vscode-as-primary-ide-target.md @@ -4,7 +4,7 @@ date: 2026-04-13 decision-makers: Marlon Kranz --- -# Use VS Code as Primary IDE Target +# Use VS Code as the primary IDE target ## Context and Problem Statement @@ -26,11 +26,11 @@ Which IDE should be the primary target? ## Decision Outcome -Chosen option: "VS Code (primary) with IntelliJ as stretch goal via LSP", because Langium generates a VS Code extension natively, and LSP provides a path to IntelliJ support via LSP4IJ without additional framework-specific work. +Chosen option: "VS Code (primary) with IntelliJ as stretch goal via LSP", because Langium's project template provides a VS Code extension that hosts the language server ([ADR-0003](0003-use-langium-as-language-workbench.md)), and LSP provides a path to IntelliJ support via LSP4IJ without additional framework-specific work. ### Consequences -- Good, because Langium generates a working VS Code extension from the grammar with no additional framework +- Good, because the language server is built on the grammar and hosted by a VS Code extension from the Langium project template, with no additional framework - Good, because LSP-based architecture enables IntelliJ support (via LSP4IJ) as a secondary target - Good, because the same language server can be reused in a browser-based Monaco editor - Neutral, because IntelliJ integration via LSP is functional but lacks deep features such as native syntax highlighting diff --git a/docs/decisions/0005-in-editor-conversion-webview-sidebar.md b/docs/decisions/0005-in-editor-conversion-webview-sidebar.md index 85cd107..171ee59 100644 --- a/docs/decisions/0005-in-editor-conversion-webview-sidebar.md +++ b/docs/decisions/0005-in-editor-conversion-webview-sidebar.md @@ -4,12 +4,12 @@ date: 2026-06-28 decision-makers: Marlon Kranz --- -# In-Editor Conversion: Webview Sidebar and Two Layers +# In-editor conversion: a 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. +Without an editor surface, the `bpmns` CLI is the only way to compile `.bpmnscript` to `.bpmn` or decompile `.bpmn` to `.bpmnscript`. +The CLI entry points (`buildAction`/`parseAction`) bake in `process.exit()` and `console.log`, which makes 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? diff --git a/docs/decisions/0006-target-users-without-bpmn-knowledge.md b/docs/decisions/0006-target-users-without-bpmn-knowledge.md index 6f45ef7..7a83ef7 100644 --- a/docs/decisions/0006-target-users-without-bpmn-knowledge.md +++ b/docs/decisions/0006-target-users-without-bpmn-knowledge.md @@ -36,16 +36,15 @@ Decided in the supervision meeting of 2026-06-30. ## Decision Outcome Chosen option: "Assume general programming literacy but no BPMN knowledge, and minimize required syntax". -Concretely, two rules bind future grammar decisions: +Two rules bind every grammar decision. -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. +The first is that 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. -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 is 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) follow both rules. 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/`. @@ -60,7 +59,7 @@ The generated scaffold is a long-lived support surface of its own, in dependency - 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 hiding BPMN vocabulary makes the DSL-to-XML correspondence less obvious for BPMN-literate readers; documentation has to carry that mapping, starting with the [glossary](../glossary.md). - 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. diff --git a/docs/decisions/0009-auto-layout-and-expansion-hint.md b/docs/decisions/0009-auto-layout-and-expansion-hint.md index 46e050b..c67745d 100644 --- a/docs/decisions/0009-auto-layout-and-expansion-hint.md +++ b/docs/decisions/0009-auto-layout-and-expansion-hint.md @@ -38,7 +38,7 @@ It serializes DI-less BPMN XML and hands it to `bpmn-auto-layout`, which lays ou 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. +The library reads an existing shape's `isExpanded` flag before computing bounds, and has 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. diff --git a/docs/decisions/0010-deterministic-synthesized-ids.md b/docs/decisions/0010-deterministic-synthesized-ids.md index b88d88d..ddc67c9 100644 --- a/docs/decisions/0010-deterministic-synthesized-ids.md +++ b/docs/decisions/0010-deterministic-synthesized-ids.md @@ -4,7 +4,7 @@ date: 2026-06-12 decision-makers: Marlon Kranz --- -# Deterministic ids for synthesized elements, reserved and respelled +# Deterministic ids for synthesized elements, reserved, and authored ids kept as written ## Context and Problem Statement @@ -14,7 +14,7 @@ How should these ids be generated, and what does an author's own name have to st 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. +An id such as `Task.1`, `user`, `Review-` or `WFP-6-` (the MIWG reference models) is a legal id the `ID` terminal 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. @@ -25,22 +25,24 @@ Renaming such an element is not a fix the user can apply without cost: the engin - 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. +- An activity id is the key the engine's history and migration match on, so a changed id is a changed process, and the script has to be able to write every id it has a name slot for. - 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 +- Structural coordinate: an id derived from the element's position in the process body, with the exact minted ids reserved against author names, 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 +- Respelling an id the `ID` terminal cannot spell on print, with a warning: every document imports, but the rebuilt document carries the respelled id, so the engine's history and migration APIs no longer know the process, and the only remedy is a rename in the model +- A second name form in backticks that spells any id the engine deploys: every id with a name slot survives import and rebuild, at the cost of a second spelling for a name and one more terminal in the grammar ## Decision Outcome -Chosen option: the structural coordinate with exactly reserved names. +Chosen option: the structural coordinate with exactly reserved names, and a name form in backticks for an authored id the `ID` terminal cannot spell. 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. +Reserving the exact forms the compiler mints, and writing every other id as it stands, keeps every document importable without changing an id the script has a slot for. The frozen id templates (from `packages/transform/src/synthesize-ids.ts`): @@ -71,7 +73,7 @@ If a base id is already in the taken set, the resolution appends `_2`, `_3`, and 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_.+_.+$/`). +It reserves by prefix `Gateway_*_(split|join|fork|loop|race)`, `Throw_*`, `EventSubProcess_*`, `Boundary_*`, `Catch_*`, the two-segment `Flow_*_*` form (`/^Flow_.+_.+$/`), and the `*_di` suffix diagram-interchange ids carry. 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. @@ -86,14 +88,20 @@ A statement named `default` in an earlier branch of such a split spells that str 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. +Every name slot takes the plain `ID` or the `QUOTED_NAME` terminal: any text on one line between backticks, where a backslash takes the next character literally, as in `` user `Task.1` `` or `` goto `end` ``. +Both carry the bare text, so `` `Review` `` and `Review` are one name to the linker, the duplicate check and every reference. +The printer writes an id plainly where the `ID` terminal lexes it as no keyword, and in backticks otherwise (`printName` in `packages/language/src/vocabulary.ts`). +Every site that writes an id goes through it: 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. 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. + +A name in backticks still becomes an id, so the validator holds a process or statement name to ASCII letters, digits, `_`, `-` and `.`, starting with a letter or `_` (`ENGINE_ID_TEXT`). +Operaton validates every file against `BPMN20.xsd`, which types an id as `xs:ID` and refuses a space or a leading digit, and moddle-xml drops an element whose id holds any letter beyond ASCII. +The import refuses a document with an id outside the same set, such as one with a `:`, which moddle-xml reads as a qualified name and the schema refuses. +The reserved forms above stay reserved in backticks too, because what collides is the id the compiler mints, not its spelling. +A code declaration's name is the root's `name` attribute rather than an id, so in backticks it takes any text on one line, and import keeps the root's `name` as written. +A code is referenced inside a trigger's parens, which the grammar parses as an expression, so a name in backticks is read there and refused anywhere else in an expression, since a variable has no backtick form. 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. @@ -112,11 +120,11 @@ The engine sets the variable under that exact name (`FormFieldHandler.handleSubm - 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. +- 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, validates and rebuilds with every activity id it carries. - 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. +- Neutral, because a name has two spellings, and the printer uses backticks only where the plain one cannot spell the id, so a script written by hand rarely shows them. - 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. @@ -125,7 +133,8 @@ The engine sets the variable under that exact name (`FormFieldHandler.handleSubm `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 backtick table in `packages/transform/test/ir-to-dsl.test.ts` prints every id site in backticks with no warning, re-parses and validates the source, and compiles it back to the same ids; the Modeler-default row asserts the printed start, end and initiator with no warning. +`tests/backtick-names.round-trip.test.ts` imports a document per id the plain form cannot spell, asserts the exact printed script, and compiles it to a document with the same ids and no model difference. 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. @@ -135,4 +144,4 @@ The round-trip normalizer (`tests/helpers/normalize-ir.ts`) uses the id patterns The id templates are frozen, because they are consumed by `astToIr`, `irToDsl`, and the round-trip normalizer. Any change requires updating all three consumers and regenerating the `invoice-approval-generated.bpmn` golden file. The frozen contract is documented in the header of `packages/transform/src/synthesize-ids.ts`, 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`. +The printed spelling is `printName` in `packages/language/src/vocabulary.ts`; the per-container check is `checkReservedNames` and the id check `checkEngineIds` in `packages/language/src/bpmn-script-validator.ts`; the variable 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 index 4e992c0..0ff7974 100644 --- a/docs/decisions/0011-structured-grammar-one-bracket-shape.md +++ b/docs/decisions/0011-structured-grammar-one-bracket-shape.md @@ -55,12 +55,9 @@ The shape is `verb Id(attributes) { children }`. 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`. +The shape alone leaves four things open. +A handler keeps the colon after its host, as in `on Pack: error(OUT_OF_STOCK, code: c)`, for the reason [ADR-0027](0027-boundary-events-attached-to-an-activity.md) gives. +`throw`, `emit` and `await` settle the same question without a colon by writing their optional name after the trigger word, as in `throw error PaymentFailed(PAYMENT_DECLINED)`. 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. diff --git a/docs/decisions/0012-honest-bpmn-import.md b/docs/decisions/0012-honest-bpmn-import.md index fe374bc..92bce91 100644 --- a/docs/decisions/0012-honest-bpmn-import.md +++ b/docs/decisions/0012-honest-bpmn-import.md @@ -8,9 +8,9 @@ decision-makers: Marlon Kranz ## 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. +An importer that promises no silent semantic loss has to say what happens to every piece of content the IR cannot carry: event definitions on start and end events, loop characteristics on tasks, whole collaborations, Operaton and camunda extension content, and lanes among them. 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? +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. @@ -30,10 +30,10 @@ Document shapes the engine rejects before `BpmnParse` sees an element pass throu - 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. +- Drop what the IR cannot carry in silence and document that the importer does so. - 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". +- 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 would call 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. @@ -45,7 +45,7 @@ Document shapes the engine rejects before `BpmnParse` sees an element pass throu 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 `BpmnParse` fails the deployment on refuses, and the message names the method, 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. @@ -66,6 +66,9 @@ Three document-level shapes follow from the same rule rather than from any one e 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. +That fallback runs per attribute name on one element and per child tag name under one parent (`Element.attributeNS`, `elementNS`, and `elementsNS`), so a file declaring both URIs would merge two spellings the engine keeps apart. +Before the swap, the camunda spellings the engine never reaches are cut from the text, and one `extensionAttribute` warning per element names them. +Children `BpmnParse` fetches by tag alone, such as list items, map entries, and an external task's `errorEventDefinition`, run under both spellings and import under both. 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. @@ -74,18 +77,26 @@ A single plaintext `bpmn:documentation` child imports onto one optional `documen 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. +A documentation child inside a value the engine reads is carried as well, since BPMN gives every expression the base element's `documentation`. +The values are a condition on a sequence flow or a conditional event, a timer's time, a repetition's count and completion condition, and the `bpmn:inputDataItem` naming the variable each run sees. +`documentation` always documents the element, and a value always takes a key named for the word that introduces it: `conditionDocumentation`, `timerDocumentation`, `countDocumentation` or `eachDocumentation`. +So `if (amount > 1000, conditionDocumentation: "...")` documents the condition and leaves the parentheses after it to the gateway, and `user R for 3 until (done, conditionDocumentation: "...")` documents the completion condition. +A `documentation` inside a condition's own parentheses is refused, since it would document a value under the element's key. +The writer puts a documented expression's text flush against its documentation child, because `BpmnParse` reads a loop count and a completion condition untrimmed and the formatter's indentation would otherwise become part of them. +A documented element variable is written as a named `bpmn:inputDataItem` instead of `operaton:elementVariable`, which `parseMultiInstanceLoopCharacteristics` reads into the same field, and a `bpmn:inputDataItem` without a name has no variable to hold the text, so its documentation is reported. + 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. +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 an inclusive split degraded to jumps, whose recompiled form takes one route where the model takes every route whose condition holds; 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 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 name of the engine method that refuses it 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 a shape the engine deploys and then fails at run time, such as an empty task listener `class`, 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`. @@ -94,12 +105,13 @@ Every one of them is warned rather than refused, because a refusal has no meanin ### 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. +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:` file and the setting spelled under both prefixes, 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. +`tests/value-documentation.round-trip.test.ts` takes every value position through one table, from script to XML and back and from that XML to a rebuild with no model difference. ## 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`. +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 `importDocument`, which `xmlToIr` calls. `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-validator-mirrors-parse-time-rules.md b/docs/decisions/0013-validator-mirrors-parse-time-rules.md index cb645d7..4deb84c 100644 --- a/docs/decisions/0013-validator-mirrors-parse-time-rules.md +++ b/docs/decisions/0013-validator-mirrors-parse-time-rules.md @@ -8,16 +8,16 @@ decision-makers: Marlon Kranz ## Context and Problem Statement -`BpmnParse` refuses a set of documents at deployment that this surface compiled without a word. +`BpmnParse` refuses a set of documents at deployment that a validator blind to the engine compiles 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. +A validator that keys a handler by its host name and groups handlers by syntactic container draws no diagnostic for any of these collisions, and the deployment fails 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. +`parseScopeStartEvent` reads neither `operaton:initiator` nor a start form off a start that is not the process's own, so both compile onto a nested start and do 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. @@ -60,10 +60,10 @@ A timer carrier is exempt for all three, since the timer job reads them off the ### 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 script that compiles also 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. +- Neutral, because a script with a nested start form, an unpaired job setting, or a `resultVariable` beside `topic` draws a diagnostic although it deploys. ### Confirmation diff --git a/docs/decisions/0014-restructure-the-ir-into-a-dsl-with-dominator-analysis.md b/docs/decisions/0014-restructure-the-ir-into-a-dsl-with-dominator-analysis.md index 4e403f4..a44fec6 100644 --- a/docs/decisions/0014-restructure-the-ir-into-a-dsl-with-dominator-analysis.md +++ b/docs/decisions/0014-restructure-the-ir-into-a-dsl-with-dominator-analysis.md @@ -33,14 +33,14 @@ How should `irToDsl` decide which subgraphs can be phrased as `if`, `while`, `pa - 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. +- 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 Operaton invoice example would come 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. +The analysis is `analyzeCfg(container): CfgAnalysis`, a pure function over one process or sub-process 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: @@ -69,12 +69,10 @@ The guard clause answers when exactly one route is unconditioned, that route bei 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`. +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 or is already printed: its last route in document order when that one qualifies, else its first that does. +That route keeps its condition over an empty branch, the walk carries on at its target after the `if`, and a second print reads the same. +Such a split stops the run when no condition holds, so its chain closes with `else fail` ([ADR-0031](0031-inclusive-and-event-based-gateways.md)) and the rebuilt split stops where the model stops. +With no such route the jumps stay, under the same `else fail`. 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. @@ -105,7 +103,7 @@ The compiler mints such an end in `lowerContainerBody` and `lowerBoundaryHandler `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. +The import warning on a plain end carrying documentation 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 documentation 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 @@ -114,15 +112,14 @@ Either way the fix it names is a rename in the diagram. - 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 fork printer, the `else fail` test 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 the restructurer reports what it drops instead of leaving the caller to find it, including where the drop changes what a recompiled document runs, such as a degraded split whose kind is written nowhere. - 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 step whose routes reconverge at a merge of another kind 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. @@ -130,7 +127,7 @@ Either way the fix it names is a rename in the diagram. - 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 modeller's document with a default-named plain end that is not last, or that carries a label, prints a reserved id and is refused until renamed, and the import-side documentation 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. diff --git a/docs/decisions/0021-operaton-engine-attributes-as-named-ir-fields.md b/docs/decisions/0021-operaton-engine-attributes-as-named-ir-fields.md index cd287eb..81dc2c9 100644 --- a/docs/decisions/0021-operaton-engine-attributes-as-named-ir-fields.md +++ b/docs/decisions/0021-operaton-engine-attributes-as-named-ir-fields.md @@ -72,7 +72,7 @@ The printer lifts the three back into the `on timer` head as long as the start p 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. +`HistoryTimeToLiveParser.parseAndValidate` refuses a process without one under the engine's default `enforceHistoryTimeToLive`, so a script without one lowers to `P30D`, `irToXml` writes `P30D` for an imported process that names none, and the printer leaves exactly that value out of the script. 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`. 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 index 3a133e5..9f02420 100644 --- 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 @@ -53,7 +53,7 @@ An element variable is optional with a collection, since `for each in batches` d 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. +There is no `runJobPriority`, because `createActivityOnScope` reads a job priority off the activity element alone and a repeated step's plain `jobPriority` already prices each run's job ([ADR-0013](0013-validator-mirrors-parse-time-rules.md)). 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. 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 index 9861740..b96d66c 100644 --- a/docs/decisions/0023-event-roots-from-usage-and-declared-codes.md +++ b/docs/decisions/0023-event-roots-from-usage-and-declared-codes.md @@ -63,7 +63,7 @@ Root ids are sanitized from the code and de-collided against the rest of the doc 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. +Import reads the pair back off each root, taking the root's `name` as written, in backticks where it is no plain name, and the code where the root has none (`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`. @@ -75,14 +75,14 @@ Outside a code position the link fails by design, and `BpmnScriptDocumentValidat 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. +Printed inside its branch block, that statement is the last statement of the block only 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. +Trigger kinds and binding fields are validated identifiers, not keywords: `on`, `throw`, `emit`, `alongside` and `await` ([ADR-0026](0026-intermediate-catch-events.md)) are the only words the 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. diff --git a/docs/decisions/0024-event-trigger-payloads-paren-slot.md b/docs/decisions/0024-event-trigger-payloads-paren-slot.md index 4a99baa..8c361f7 100644 --- a/docs/decisions/0024-event-trigger-payloads-paren-slot.md +++ b/docs/decisions/0024-event-trigger-payloads-paren-slot.md @@ -15,7 +15,7 @@ Timer carries something different: a word choosing which of BPMN's three timer f 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. +Two narrower questions ride along with it: how a timer's payload text becomes one of BPMN's three timer definition kinds without picking the wrong one unnoticed, 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 @@ -33,11 +33,11 @@ Two narrower questions ride along with it: how a timer's payload text becomes on - A binding-marker keyword freeing the parens for bindings only, writing them after a marker such as `as (...)`, which spends a new reserved word on a collision structural lookahead already dissolves for free and breaks the parameter-list reading (`on error(X, code: c, message: m)`, read like `catch (Exception e)`) that motivated the binding shape in the first place. - A string-wrapped condition, written as a quoted literal instead of a real expression, which reads worse than the language's own `if (...)` and throws away everything a real expression AST buys, including undeclared-variable checking, type checking, symbol participation, and precise highlighting. - Reserving `condition` as a keyword to dispatch the parens, which would be a lexer-global reservation, unusable as an ordinary variable name anywhere in a file, for exactly the reason the trigger and binding words already stay soft identifiers. -- Timer particles, 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. +- 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 resolve to the wrong form unnoticed. - 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. +- Dropping them on import without a word, 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. @@ -73,4 +73,4 @@ A message or signal name has neither need, so no declaration form is introduced - 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. +- 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 resolve to the wrong timer kind unnoticed. diff --git a/docs/decisions/0025-events-at-the-process-boundary.md b/docs/decisions/0025-events-at-the-process-boundary.md index 25a5504..174a496 100644 --- a/docs/decisions/0025-events-at-the-process-boundary.md +++ b/docs/decisions/0025-events-at-the-process-boundary.md @@ -69,7 +69,7 @@ Neither clause needs a new reserved word. 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 thrown message carries the implementation that makes the engine send it on its `bpmn:messageEventDefinition`, as [ADR-0016](0016-task-kinds-on-the-authoring-surface.md) records. 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. diff --git a/docs/decisions/0026-intermediate-catch-events.md b/docs/decisions/0026-intermediate-catch-events.md index cbc5040..f529bf3 100644 --- a/docs/decisions/0026-intermediate-catch-events.md +++ b/docs/decisions/0026-intermediate-catch-events.md @@ -31,7 +31,7 @@ Three questions settle together: what word introduces it, given that trigger wor ## 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). +Link joins that scope as well, for a reason of its own ([ADR-0030](0030-link-events-for-import-round-trip-symmetry.md)). 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. diff --git a/docs/decisions/0028-compensation-as-subprocess-undo-block.md b/docs/decisions/0028-compensation-as-subprocess-undo-block.md index dfc7c9e..4a87dd4 100644 --- a/docs/decisions/0028-compensation-as-subprocess-undo-block.md +++ b/docs/decisions/0028-compensation-as-subprocess-undo-block.md @@ -20,7 +20,7 @@ The question this decision settles is which of BPMN's two attachment shapes, or - 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. +- The honest import contract (ADR-0012) already distinguishes constructs the importer refuses from ones it drops with a warning; a construct this decision does not model needs to land on one side of that line, not the other. ## Considered Options @@ -31,7 +31,7 @@ The question this decision settles is which of BPMN's two attachment shapes, or ## 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. +Chosen option: "Compensation handlers only as a subprocess's `on compensation` block", 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. @@ -58,7 +58,7 @@ BPMNscript reaches the same granularity without a second mechanism: wrapping the ### 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. +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 an 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. diff --git a/docs/decisions/0029-work-that-can-be-given-up.md b/docs/decisions/0029-work-that-can-be-given-up.md index c6b1328..69e709e 100644 --- a/docs/decisions/0029-work-that-can-be-given-up.md +++ b/docs/decisions/0029-work-that-can-be-given-up.md @@ -10,7 +10,7 @@ decision-makers: Marlon Kranz 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. +Without a container to put them in, any document carrying the element refuses on import and the round trip stops 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 @@ -51,7 +51,7 @@ A handler on a block whose body holds no cancel end warns for the same reason, s ### 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 document the engine runs as work that can be given up imports, prints, and recompiles byte for byte. - 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. 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 index fe33c1f..88f4255 100644 --- a/docs/decisions/0030-link-events-for-import-round-trip-symmetry.md +++ b/docs/decisions/0030-link-events-for-import-round-trip-symmetry.md @@ -8,16 +8,16 @@ decision-makers: Marlon Kranz ## 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. +Authoring gives `await` no reason to take a link trigger beside message, timer, signal, and conditional ([ADR-0026](0026-intermediate-catch-events.md)): a link is 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 argument holds for authoring, but it says nothing about import. +A modeller's existing document can carry a link pair, and without a construct for it the importer has to refuse `bpmn:linkEventDefinition` 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. + Modelers built on bpmn-js, the Camunda Modeler among them, offer both ends of a link pair, and a document drawn with one should import. - 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. @@ -29,7 +29,7 @@ The question this decision settles is whether a construct authoring does not nee ## 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. +- Refuse 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. @@ -43,7 +43,7 @@ This is the one `emit` whose chain ends; the two printed words `emit link` decid 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. +`await` takes 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. @@ -70,7 +70,7 @@ That is why the argument for this surface is notation fidelity and not execution - 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. +- Good, because every `await` is 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`. diff --git a/docs/decisions/0031-inclusive-and-event-based-gateways.md b/docs/decisions/0031-inclusive-and-event-based-gateways.md index 2549412..c3a8c6a 100644 --- a/docs/decisions/0031-inclusive-and-event-based-gateways.md +++ b/docs/decisions/0031-inclusive-and-event-based-gateways.md @@ -1,6 +1,6 @@ --- status: accepted -date: 2026-08-30 +date: 2026-10-17 decision-makers: Marlon Kranz --- @@ -17,6 +17,10 @@ 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`). +A split can also stop the run on purpose. +`ExclusiveGatewayActivityBehavior.doLeave` and `InclusiveGatewayActivityBehavior.execute` throw `ENGINE-02004 No outgoing sequence flow for the element with id '...' could be selected for continuing the process` when no flow is taken and none is the default, and a step whose routes are all conditioned throws `ENGINE-02006` (`BpmnActivityBehavior.handleNoTransitions`). +A variant of the Operaton invoice example whose approval split names no default is one such model, and a script needs a way to say it. + ## 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. @@ -27,6 +31,8 @@ A gateway's `name` survives XML to IR to XML, then dies at the print hop, where - 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. +- The engine's no-match failure is not a BPMN error: no error boundary or `on error` handler catches it. +- A new reserved word costs authors a name, and a variable has no backtick form to win it back. ## Considered Options @@ -44,6 +50,10 @@ A gateway's `name` survives XML to IR to XML, then dies at the print hop, where - 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. +- For a split that fails when no condition holds, a closing `else fail` on an `if` chain and on a `parallel` block, with `fail` a soft word legal only after `else`. +- The same form with `fail` a reserved word, which the TextMate grammar would colour for free, but it would take `var fail` and `user fail` out of the language. +- `else throw`, which reads as an error, but `throw` already raises a BPMN error that a handler can catch, and this failure cannot be caught. +- No form for that split, and a warning on import, which leaves every such import changing what runs. - 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 @@ -53,10 +63,18 @@ Nothing in the surface names a gateway, and ADR-0010's positional ids keep worki 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. +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 and the block does not close with `else fail`, running straight to the join, the rule `lowerIf` applies at the same position. 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. +A split that stops the run when no condition holds closes with `else fail`, and `fail` is a soft word legal only there, so it stays a legal variable and step name. +`lowerIf` reserves no default flow for it and `lowerParallel` adds no fallback to the join, so the compiled split carries only its conditioned flows and the engine stops the run exactly as the imported model did. +The printer writes `else fail` for every exclusive or inclusive split, gateway or step, whose model routes are all conditioned and that names no default, except a fork that degrades to jumps, whose chain of jumps closes without it. +An `if` chain is conditioned on every branch by its shape, so `else fail` is always legal there. +On a `parallel` block the validator refuses it with no conditioned branch, beside an unconditioned branch, which always runs, and beside an `else` branch, which runs whenever no condition holds, the same reasons it refuses an `else` in those places. +A loop takes no `else fail`, because the route out of a `while` or `do ... while` is its fallback, and the remaining exits of a loop head print as an `if` chain that takes `else fail` like any other. +Beside `else fail` an empty conditioned branch is how a case carries on rather than failing, so it draws no empty-branch warning. + 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. @@ -94,11 +112,14 @@ Neither that shape nor the one Operaton refuses is authorable, so the extra widt - 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. +- Good, because an imported split whose routes are all conditioned and that names no default rebuilds unchanged, the invoice variant included. - 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 an imported split whose routes do not meet again prints its continuing route as an empty `else if` branch rather than as a fall-through. +- Bad, because the soft word `fail` needs its own highlighting rule in the TextMate grammar and a test that pins it to the vocabulary. - 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 @@ -107,6 +128,9 @@ Neither that shape nor the one Operaton refuses is authorable, so the extra widt `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. +`packages/language/test/` also pins both `else fail` heads, each of its validator rules, its completion after `else` and the highlighting of `fail`. +`tests/else-fail.round-trip.test.ts` pins that an imported exclusive and inclusive split with no default prints as `else fail` and compares with no differences and no warning, and `tests/e2e/claim-routing.test.ts` runs `examples/spring-boot/processes/claim-routing.bpmnscript` on Operaton, where a holding condition runs its route and with none holding both the start request and the task completion before the inclusive fork fail with `ENGINE-02004`. + ## More Information Operaton behavior was read from `BpmnParse.java`, `InclusiveGatewayActivityBehavior.java`, `ExclusiveGatewayActivityBehavior.java` and `EventBasedGatewayActivityBehavior.java` in the `operaton/operaton` repository. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 88f587e..9240da4 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -3,39 +3,40 @@ 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. +The syntax a record mentions is taught in full in the [language reference](../../packages/language/README.md). -| 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 | +| 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 the Apache 2.0 license | accepted | +| [0002](0002-use-mono-repo-structure.md) | Use a mono-repo structure | accepted | +| [0003](0003-use-langium-as-language-workbench.md) | Use Langium as the language workbench | accepted | +| [0004](0004-use-vscode-as-primary-ide-target.md) | Use VS Code as the primary IDE target | accepted | +| [0005](0005-in-editor-conversion-webview-sidebar.md) | In-editor conversion: a 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 authored ids kept as written | 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/decisions/adr-template.md b/docs/decisions/adr-template.md index cf42613..e5f7187 100644 --- a/docs/decisions/adr-template.md +++ b/docs/decisions/adr-template.md @@ -1,6 +1,6 @@ --- # These are optional metadata elements. Feel free to remove any of them. -status: '{proposed | rejected | accepted | deprecated | … | superseded by ADR-0123}' +status: '{proposed | rejected | accepted | deprecated | ... | superseded by ADR-0123}' date: { YYYY-MM-DD when the decision was last updated } decision-makers: { list everyone involved in the decision } consulted: @@ -25,26 +25,26 @@ informed: - {decision driver 1, for instance, a desired software quality, faced concern, constraint or force} - {decision driver 2} -- … +- ... ## Considered Options - {title of option 1} - {title of option 2} - {title of option 3} -- … +- ... ## Decision Outcome -Chosen option: "{title of option 1}", because {justification. e.g., only option, which meets k.o. criterion decision driver | which resolves force {force} | … | comes out best (see below)}. +Chosen option: "{title of option 1}", because {justification. e.g., only option, which meets k.o. criterion decision driver | which resolves force {force} | ... | comes out best (see below)}. ### Consequences -- Good, because {positive consequence, e.g., improvement of one or more desired qualities, …} -- Bad, because {negative consequence, e.g., compromising one or more desired qualities, …} -- … +- Good, because {positive consequence, e.g., improvement of one or more desired qualities, ...} +- Bad, because {negative consequence, e.g., compromising one or more desired qualities, ...} +- ... @@ -60,7 +60,7 @@ Chosen option: "{title of option 1}", because {justification. e.g., only option, -{example | description | pointer to more information | …} +{example | description | pointer to more information | ...} - Good, because {argument a} - Good, because {argument b} @@ -69,16 +69,16 @@ Chosen option: "{title of option 1}", because {justification. e.g., only option, - Neutral, because {argument c} - Bad, because {argument d} -- … +- ... ### {title of other option} -{example | description | pointer to more information | …} +{example | description | pointer to more information | ...} - Good, because {argument a} - Neutral, because {argument b} - Bad, because {argument c} -- … +- ... diff --git a/docs/glossary.md b/docs/glossary.md index d75f13c..2524836 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -24,11 +24,11 @@ Terms that show up across the READMEs, the ADRs, and the code. BPMNscript is a DSL for writing BPMN processes as text. - **DI**: diagram interchange. The `` part of a BPMN file that stores diagram coordinates, meaning where each box sits. - BPMNscript regenerates it automatically on export; see [ADR-0009](decisions/0009-auto-layout-and-expansion-hint.md). + BPMNscript generates it on every compile and discards it on import ([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. - Its interoperability conventions, such as explicit ``/`` references on every node, are what Operaton expects in a BPMN file. + It publishes BPMN reference models and interchange conventions, such as explicit ``/`` references on every node, which the exporter follows. ## The toolchain @@ -45,16 +45,16 @@ Terms that show up across the READMEs, the ADRs, and the code. 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-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. +- **Validator**: checks the rules a parser can't express, such as types and engine constraints, and reports errors in the editor and the CLI. The full list of checks is in [packages/language/README.md](../packages/language/README.md#diagnostics). - **LSP**: Language Server Protocol. The standard that lets one language server provide highlighting, autocompletion, and inline errors to any editor that speaks it. - Langium implements it for us; the VS Code extension is the host. + Langium provides the server, and the VS Code extension hosts it. - **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-0021](decisions/0021-operaton-engine-attributes-as-named-ir-fields.md)). + Declaring the nested shapes lets an import warning name the step a dropped element sat on ([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 tests use it to check what each direction keeps, warns about, or drops. ## The repository diff --git a/eslint.config.js b/eslint.config.js index 8a081c8..8aa3b63 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,11 +1,8 @@ -import bpmnIo from 'eslint-plugin-bpmn-io'; +import js from '@eslint/js'; import prettierConfig from 'eslint-config-prettier'; import tsParser from '@typescript-eslint/parser'; import globals from 'globals'; -// The bpmn-io recommended config is an array of flat-config objects. -const recommended = bpmnIo.configs.recommended; - export default [ { ignores: [ @@ -34,9 +31,30 @@ export default [ }, // ----------------------------------------------------------------------- - // 2. Base rules — bpmn-io recommended applied to all remaining files. + // 2. Base rules — ESLint recommended plus a few stricter checks, applied + // to all remaining files. // ----------------------------------------------------------------------- - ...recommended, + js.configs.recommended, + { + languageOptions: { + ecmaVersion: 2022, + globals: globals['shared-node-browser'], + }, + rules: { + 'no-bitwise': 'error', + 'no-constant-condition': ['error', { checkLoops: true }], + 'no-unused-vars': [ + 'error', + { + args: 'none', + ignoreRestSiblings: true, + varsIgnorePattern: '^_', + caughtErrors: 'none', + }, + ], + 'spaced-comment': ['error', 'always'], + }, + }, // ----------------------------------------------------------------------- // 3. TypeScript source files — add the TS parser and Node globals. diff --git a/examples/README.md b/examples/README.md index 98ce191..6d96b4f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,20 +1,19 @@ # Examples Deployment fixtures for running a compiled `.bpmn` file on Operaton. - -One mode exists: `spring-boot/`, with Operaton embedded in a Spring Boot application. +There is one so far, `spring-boot/`. ## `spring-boot/` -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. +Operaton 2.1.0 embedded in a Spring Boot 4.0.6 application on Java 17, serving the Operaton REST API on port 8080. +It's packaged as a Docker image so the end-to-end tests can start and stop it, and its [README](spring-boot/README.md#running-processes-on-operaton-demo) is a hands-on tour of running the processes in Cockpit and Tasklist. -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`. +Each source under `spring-boot/processes/` covers one construct or combination, and `bpmns build` on any of them produces a deployable `.bpmn`. | Source | Covers | | ----------------------- | -------------------------------------------------------------------------- | -| `invoice-approval` | `if`/`else` desugaring | -| `parallel-approval` | `parallel { { } { } }` desugaring | +| `invoice-approval` | `if`/`else` | +| `parallel-approval` | `parallel { { } { } }` | | `service-delegate` | The `delegate` service binding, against a Spring-managed bean | | `service-expression` | The `expression` service binding, a JUEL expression Operaton evaluates | | `external-task` | The `topic` binding, against a worker polling `print-label` | @@ -23,8 +22,8 @@ The DSL sources under `spring-boot/processes/` cover one construct or construct | `order-handling` | Sub-process containment, plus interrupting and non-interrupting boundaries | | `order-recovery` | The error and escalation layer, both handler kinds | | `order-reminder` | The timer and message triggers | -| `loan-approval` | The `demo` walkthrough | -| `loan-approval-kopp` | The `demo` walkthrough, parallel-rating variant | +| `loan-approval` | The demo walkthrough | +| `loan-approval-kopp` | The demo walkthrough, parallel-rating variant | | `awaiting-confirmation` | `await message`, released by an outside correlation | | `charge-with-recovery` | An error boundary on a service task host | | `compensating-saga` | Compensation raised from a process-level error handler | @@ -44,19 +43,18 @@ The DSL sources under `spring-boot/processes/` cover one construct or construct | `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 | +| `claim-routing` | `else fail` closing an `if` chain and a conditioned `parallel` block | -[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 +### End-to-end tests -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. +The tests in `tests/e2e/` use [testcontainers](https://testcontainers.com/) to start the Docker image, deploy compiled BPMN over the Operaton REST API, start instances, and check what the engine does. +The suite builds the image and starts one engine, and every test file runs against it. `deploy-sweep` deploys every golden under `tests/golden/`, every fixture under `tests/fixtures/`, and every example here, once as written and once rebuilt from the tool's own print of it. -Docker tests run by default, in CI too, and are skipped only when `SKIP_DOCKER_TESTS=true`. +The Docker tests run by default, in CI too, and are skipped only when `SKIP_DOCKER_TESTS=true`. -## Adding a new deployment mode +## Adding a deployment -1. Create a subdirectory with a `README.md` and whatever runtime files it needs, such as a `pom.xml`, `Dockerfile`, or `docker-compose.yml`. +1. Create a subdirectory with a `README.md` and the 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. Have `startFixture` in `tests/fixtures/index.ts` pick the adapter, for example by a mode argument, since it starts the one adapter today. +3. Make `startFixture` in `tests/fixtures/index.ts` choose between adapters, for example by a mode argument; it starts the Spring Boot one unconditionally today. diff --git a/examples/spring-boot/README.md b/examples/spring-boot/README.md index 3137773..458827b 100644 --- a/examples/spring-boot/README.md +++ b/examples/spring-boot/README.md @@ -1,11 +1,9 @@ # examples/spring-boot -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. +Operaton 2.1.0 embedded in Spring Boot 4.0.6, the engine the end-to-end tests deploy to. +It starts with nothing deployed, and each test deploys its processes over REST. -It doubles as a hands-on demo. -The `demo` profile compiles and deploys the example processes and serves the Operaton web apps, Cockpit and Tasklist, so you can start instances and complete tasks by hand. -See [Running processes on Operaton](#running-processes-on-operaton-demo). +It's also a hands-on demo: the `demo` profile deploys the compiled example processes and serves the Operaton web apps, Cockpit and Tasklist, so you can start instances and complete tasks by hand ([Running processes on Operaton](#running-processes-on-operaton-demo)). ## Stack @@ -24,7 +22,7 @@ docker build -t bpmnscript-invoice . docker run -d --name bpmnscript-invoice -p 8080:8080 bpmnscript-invoice ``` -Wait for the container to become healthy, roughly 30 to 60 seconds on a first run and faster with cached layers, then check the engine is up: +Wait for the container to become healthy, which takes 30 to 60 seconds, then check the engine is up: ```bash curl http://localhost:8080/engine-rest/engine @@ -37,7 +35,7 @@ When you're done: docker stop bpmnscript-invoice && docker rm bpmnscript-invoice ``` -## REST endpoints the testcontainers harness uses +## REST endpoints the tests use The deployment and lifecycle calls the fixture adapter makes: @@ -49,21 +47,21 @@ 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`. +The assertions query more of the same API through `tests/helpers/engine-rest.ts`. ## Admin credentials Username `demo`, password `demo`, configured in `src/main/resources/application.yml`. -> For any non-test deployment, replace the `operaton.bpm.admin-user` block with a real credential and probably move it to an env var. +> For anything beyond local testing, replace the `operaton.bpm.admin-user` block with a real credential, for example from an environment variable. ## Running processes on Operaton (demo) -Compile any BPMNscript process to BPMN, deploy it to a real Operaton engine, and drive it from Cockpit and Tasklist. -Loan approval is the worked example, and you can add your own (see [Add your own process](#add-your-own-process)). +Compile the BPMNscript processes, deploy them to a real Operaton engine, and drive them from Cockpit and Tasklist. +Loan approval is the worked example, and you can [add your own process](#add-your-own-process). -The `.bpmnscript` sources live in [`processes/`](processes). -The compile step turns each one into a deployable `.bpmn` under `src/main/resources/processes/`, which is generated and git-ignored, and the `demo` profile auto-deploys them all on startup. +The sources are in [`processes/`](processes). +The compile step writes a `.bpmn` for each into `src/main/resources/processes/`, which is git-ignored, and the `demo` profile deploys them all on startup. ### 1. Build the toolchain @@ -94,10 +92,10 @@ From `examples/spring-boot/`, either run it with Maven: mvn clean spring-boot:run -Dspring-boot.run.profiles=demo ``` -The `clean` matters. -It wipes `target/`, so a process you renamed or removed doesn't linger there as a stale compiled copy; two deployed definitions with the same key make Operaton refuse to start (`The deployment contains definitions with the same key ...`). +Keep the `clean`. +It empties `target/`, where a process you renamed or removed would otherwise linger as a stale copy, and two definitions with the same key make Operaton refuse to start (`The deployment contains definitions with the same key ...`). -Or run it in Docker, noting the `SPRING_PROFILES_ACTIVE=demo` env var: +Or run it in Docker with `SPRING_PROFILES_ACTIVE=demo`: ```bash docker build -t bpmnscript-demo . @@ -107,14 +105,14 @@ docker run -d --name bpmnscript-demo -p 8080:8080 -e SPRING_PROFILES_ACTIVE=demo ### 4. Open the web apps Go to and log in with `demo` / `demo`. -Every compiled process is deployed, so pick one in Cockpit to watch instances and live diagrams, or in Tasklist to start one and complete its tasks. +Every compiled process is deployed: Cockpit shows instances and live diagrams, and Tasklist starts processes and completes their tasks. ### The loan approval walkthrough -The loan approval example from the [WS-BPEL 2.0 specification](https://docs.oasis-open.org/wsbpel/2.0/wsbpel-v2.0.html). +This is 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. +Everything else, large loans and small loans that aren't low-risk, goes to a human "Approve loan" task. 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). @@ -142,7 +140,7 @@ 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` 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: +Inputs to try: - `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. @@ -159,7 +157,7 @@ Inputs worth trying: ## Configuration notes -Classpath auto-deployment is off by default (`operaton.bpm.auto-deployment-enabled: false`). -The test harness deploys everything over REST, which keeps the fixture stateless between runs. -The `demo` profile (`application-demo.yml`) turns it on to deploy the compiled processes under `src/main/resources/processes/` at startup. -H2 runs in-memory, so process state lives only as long as the container, and the two-stage Dockerfile keeps the runtime image down to the fat jar on a plain JRE. +Classpath auto-deployment is off by default (`operaton.bpm.auto-deployment-enabled: false`), so the tests deploy everything over REST and every run starts from an empty engine. +The `demo` profile (`application-demo.yml`) turns it on to deploy the compiled processes at startup. +The history level is `audit`, which records the task and variable history the end-to-end assertions read. +H2 runs in memory, so process state lives only as long as the container, and the two-stage Dockerfile ships only the fat jar on a plain JRE. diff --git a/examples/spring-boot/processes/claim-routing.bpmnscript b/examples/spring-boot/processes/claim-routing.bpmnscript new file mode 100644 index 0000000..9185cf2 --- /dev/null +++ b/examples/spring-boot/processes/claim-routing.bpmnscript @@ -0,0 +1,29 @@ +// Every route below runs under a condition and `else fail` adds no fallback, +// so a claim no condition matches stops with the engine's error instead of +// slipping past the review. The `if` fails as the claim starts, the +// `parallel` block when the first review is completed. + +process claim-routing(label: "Claim routing") { + var amount: number + var injury: boolean + + start ClaimReceived(label: "Claim received") + + if (amount <= 1000) { + user QuickReview(label: "Review the small claim", assignee: "demo") + } else if (amount <= 50000) { + user FullReview(label: "Review the claim in full", assignee: "demo") + } else fail + + parallel { + if (injury) { + user MedicalCheck(label: "Check the medical report", assignee: "demo") + } + if (amount > 500) { + user FraudCheck(label: "Check the claim for fraud", assignee: "demo") + } + else fail + } + + end ClaimSettled(label: "Claim settled") +} diff --git a/examples/spring-boot/processes/order-intake.bpmnscript b/examples/spring-boot/processes/order-intake.bpmnscript index db77d7b..c1be2d7 100644 --- a/examples/spring-boot/processes/order-intake.bpmnscript +++ b/examples/spring-boot/processes/order-intake.bpmnscript @@ -1,12 +1,9 @@ -// 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") user ConfirmOrder(label: "Confirm the order", assignee: "demo") - emit message NotifyWarehouse("WarehouseNotified") + emit message NotifyWarehouse("WarehouseNotified", class: "com.example.demo.LogDelegate") - throw message OrderAcknowledged("OrderAcknowledged") + throw message OrderAcknowledged("OrderAcknowledged", class: "com.example.demo.LogDelegate") } diff --git a/package-lock.json b/package-lock.json index bcdea8d..62d5d72 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bpmn-script-workspace", - "version": "0.0.1", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bpmn-script-workspace", - "version": "0.0.1", + "version": "0.1.0", "license": "Apache-2.0", "workspaces": [ "packages/language", @@ -16,14 +16,14 @@ "tests" ], "devDependencies": { - "@types/node": "^26.4.0", - "@typescript-eslint/parser": "^8.69.0", - "@vitest/coverage-v8": "^4.1.11", - "eslint": "^9.0.0", + "@eslint/js": "^10.0.1", + "@types/node": "^26.6.5", + "@typescript-eslint/parser": "^8.71.2", + "@vitest/coverage-v8": "^5.0.5", + "eslint": "^10.13.0", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-bpmn-io": "^2.3.1", - "prettier": "^3.9.6", - "shx": "~0.4.0", + "globals": "^17.13.0", + "prettier": "^3.9.10", "typescript": "~6.0.0" }, "engines": { @@ -288,80 +288,67 @@ "resolved": "packages/transform", "link": true }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, "node_modules/@chevrotain/cst-dts-gen": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz", - "integrity": "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==", + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-13.2.0.tgz", + "integrity": "sha512-bA9dvWlhAUcHkNwU+8w9yiUxJPlj/Lm3VqS7MD+NbIR/7LmpNLuK/AWNFdCo/T3SK5ND0/UHf3IJvl/Jn/i6yg==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/gast": "12.0.0", - "@chevrotain/types": "12.0.0" + "@chevrotain/gast": "13.2.0", + "@chevrotain/types": "13.2.0" } }, "node_modules/@chevrotain/gast": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-12.0.0.tgz", - "integrity": "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==", + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-13.2.0.tgz", + "integrity": "sha512-bCARdAsG+eEbOveSXcOAC7pVxA04I1ionzeMOwIYf5NX7SaS7zgn4Dgug2BSqoL+iyKeMNSorQjsFSHJHsCNwg==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/types": "12.0.0" + "@chevrotain/types": "13.2.0" } }, "node_modules/@chevrotain/regexp-to-ast": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-12.0.0.tgz", - "integrity": "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==", + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-13.2.0.tgz", + "integrity": "sha512-kxpUkLtiaK09ph+dib+Yvm+3sWSnuwlo25gqfKRgTPNmF6H7uW4gVkMjaRdtnN783S9sM6vUNWh9CsxYX4QwKw==", "license": "Apache-2.0" }, "node_modules/@chevrotain/types": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-12.0.0.tgz", - "integrity": "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==", + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-13.2.0.tgz", + "integrity": "sha512-7qrYHRlKfWf2AvBfJi5eVOrKAkbxvMFsy/iIHTGIiApNcEecCaVIdR+mIQhyCBsTDJxBz4JWXHMo6krbhR9hVg==", "license": "Apache-2.0" }, "node_modules/@chevrotain/utils": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-12.0.0.tgz", - "integrity": "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==", + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-13.2.0.tgz", + "integrity": "sha512-SzKYY68rseOrb9KILVzPsl6SjkQtdWw01SRGB5aCVI5kFcHltQsOYV3JB0Ffst72DgkvHCyDQ0NLwxtmCCf97Q==", "license": "Apache-2.0" }, - "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -847,173 +834,95 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.7", + "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", - "minimatch": "^3.1.5" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-array/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "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": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" + "minimatch": "^10.2.4" }, "engines": { - "node": "*" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0" + "@eslint/core": "^1.2.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "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": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0", + "@eslint/core": "^1.2.1", "levn": "^0.4.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@grpc/grpc-js": { - "version": "1.14.4", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", - "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "version": "1.14.6", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.6.tgz", + "integrity": "sha512-SgC/B3/VZ8lTyOZ4Cwk6f1skWhmkNAaVfiX90qd5u1rRXGKk1//8FTUEN9GvOUq7rfWIqAYkA3CoyumYVpOS6g==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1185,6 +1094,30 @@ "url": "https://opencollective.com/js-sdsl" } }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@kwsites/file-exists": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", @@ -1427,69 +1360,13 @@ "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", - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@oxc-project/types": { "version": "0.147.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz", "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/Boshen" } @@ -1584,6 +1461,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1601,6 +1479,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1618,6 +1497,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1635,6 +1515,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1652,6 +1533,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1669,6 +1551,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1686,6 +1569,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1703,6 +1587,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1720,6 +1605,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1737,6 +1623,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1754,6 +1641,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1771,6 +1659,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1788,6 +1677,7 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1805,6 +1695,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1822,6 +1713,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1831,7 +1723,8 @@ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@secretlint/core": { "version": "10.2.2", @@ -1903,24 +1796,6 @@ "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", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1962,6 +1837,13 @@ "@types/ssh2": "*" } }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1977,13 +1859,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.4.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz", - "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==", + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~8.3.0" + "undici-types": "~8.9.0" } }, "node_modules/@types/ssh2": { @@ -2023,24 +1905,17 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/vscode": { - "version": "1.134.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.134.0.tgz", - "integrity": "sha512-NDEu0hg4sF7+vvFsADsktqUJ6f80LHSZvVK2Ovo1XiQ0/VHck1O3zst+ZZyVA/uvz6vo6LcuoqU2q48YMqOwWw==", - "dev": true, - "license": "MIT" - }, "node_modules/@typescript-eslint/parser": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.69.0.tgz", - "integrity": "sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.2.tgz", + "integrity": "sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.69.0", - "@typescript-eslint/types": "8.69.0", - "@typescript-eslint/typescript-estree": "8.69.0", - "@typescript-eslint/visitor-keys": "8.69.0", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", "debug": "^4.4.3" }, "engines": { @@ -2056,14 +1931,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.69.0.tgz", - "integrity": "sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz", + "integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.69.0", - "@typescript-eslint/types": "^8.69.0", + "@typescript-eslint/tsconfig-utils": "^8.71.2", + "@typescript-eslint/types": "^8.71.2", "debug": "^4.4.3" }, "engines": { @@ -2078,14 +1953,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.69.0.tgz", - "integrity": "sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.2.tgz", + "integrity": "sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.69.0", - "@typescript-eslint/visitor-keys": "8.69.0" + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2096,9 +1971,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.69.0.tgz", - "integrity": "sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz", + "integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==", "dev": true, "license": "MIT", "engines": { @@ -2113,9 +1988,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.69.0.tgz", - "integrity": "sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz", + "integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==", "dev": true, "license": "MIT", "engines": { @@ -2127,16 +2002,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.69.0.tgz", - "integrity": "sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz", + "integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.69.0", - "@typescript-eslint/tsconfig-utils": "8.69.0", - "@typescript-eslint/types": "8.69.0", - "@typescript-eslint/visitor-keys": "8.69.0", + "@typescript-eslint/project-service": "8.71.2", + "@typescript-eslint/tsconfig-utils": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -2155,13 +2030,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.69.0.tgz", - "integrity": "sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.2.tgz", + "integrity": "sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/types": "8.71.2", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -2187,3995 +2062,2078 @@ "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", - "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", - "cpu": [ - "arm" - ], + "node_modules/@vitest/coverage-v8": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.5.tgz", + "integrity": "sha512-4KpkGdIrSqBihx/JyP/e+S+IKB98etg1XDQiLAxksLfcShnwi1VReGeOT9NI43hLHbknosKp/1CCLn9Zgcs8mg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/istanbul-lib-coverage": "^1.0.2", + "@vitest/istanbul-lib-report": "^1.0.2", + "ast-v8-to-istanbul": "^1.0.7", + "magicast": "^0.5.5", + "obug": "^3.0.0", + "std-env": "^4.3.0", + "tinyrainbow": "^3.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "5.0.5", + "vitest": "5.0.5" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } }, - "node_modules/@unrs/resolver-binding-android-arm64": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", - "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", - "cpu": [ - "arm64" - ], + "node_modules/@vitest/istanbul-lib-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-1.0.2.tgz", + "integrity": "sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "engines": { + "node": ">=22" + } }, - "node_modules/@unrs/resolver-binding-darwin-arm64": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", - "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", - "cpu": [ - "arm64" - ], + "node_modules/@vitest/istanbul-lib-report": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-1.0.2.tgz", + "integrity": "sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] + "dependencies": { + "@vitest/istanbul-lib-coverage": "1.0.2" + }, + "engines": { + "node": ">=22" + } }, - "node_modules/@unrs/resolver-binding-darwin-x64": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", - "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", - "cpu": [ - "x64" - ], + "node_modules/@vitest/mocker": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz", + "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-freebsd-x64": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", - "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", - "cpu": [ - "x64" - ], + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.5", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz", + "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "funding": { + "url": "https://opencollective.com/vitest" + } }, - "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", - "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", - "cpu": [ - "arm" - ], + "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", - "optional": true, - "os": [ - "linux" - ] + "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/@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", - "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", - "cpu": [ - "arm" - ], + "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, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "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/@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", - "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "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": "MIT", + "license": "SEE LICENSE IN LICENSE.txt", "optional": true, "os": [ - "linux" + "alpine" ] }, - "node_modules/@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", - "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "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": [ - "arm64" + "x64" ], "dev": true, - "license": "MIT", + "license": "SEE LICENSE IN LICENSE.txt", "optional": true, "os": [ - "linux" + "alpine" ] }, - "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", - "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "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": [ - "loong64" + "arm64" ], "dev": true, - "license": "MIT", + "license": "SEE LICENSE IN LICENSE.txt", "optional": true, "os": [ - "linux" + "darwin" ] }, - "node_modules/@unrs/resolver-binding-linux-loong64-musl": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", - "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "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": [ - "loong64" + "x64" ], "dev": true, - "license": "MIT", + "license": "SEE LICENSE IN LICENSE.txt", "optional": true, "os": [ - "linux" + "darwin" ] }, - "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", - "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "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": [ - "ppc64" + "arm" ], "dev": true, - "license": "MIT", + "license": "SEE LICENSE IN LICENSE.txt", "optional": true, "os": [ "linux" ] }, - "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", - "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "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": [ - "riscv64" + "arm64" ], "dev": true, - "license": "MIT", + "license": "SEE LICENSE IN LICENSE.txt", "optional": true, "os": [ "linux" ] }, - "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", - "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "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": [ - "riscv64" + "x64" ], "dev": true, - "license": "MIT", + "license": "SEE LICENSE IN LICENSE.txt", "optional": true, "os": [ "linux" ] }, - "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", - "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "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": [ - "s390x" + "arm64" ], "dev": true, - "license": "MIT", + "license": "SEE LICENSE IN LICENSE.txt", "optional": true, "os": [ - "linux" + "win32" ] }, - "node_modules/@unrs/resolver-binding-linux-x64-gnu": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", - "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "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", + "license": "SEE LICENSE IN LICENSE.txt", "optional": true, "os": [ - "linux" + "win32" ] }, - "node_modules/@unrs/resolver-binding-linux-x64-musl": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", - "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", - "cpu": [ - "x64" - ], + "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", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": ">=18" + } }, - "node_modules/@unrs/resolver-binding-openharmony-arm64": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", - "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", - "cpu": [ - "arm64" - ], + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } }, - "node_modules/@unrs/resolver-binding-wasm32-wasi": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", - "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", - "cpu": [ - "wasm32" - ], + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", "dev": true, "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "bin": { + "acorn": "bin/acorn" }, "engines": { - "node": ">=14.0.0" + "node": ">=0.4.0" } }, - "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "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", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" + "engines": { + "node": ">= 14" } }, - "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "tslib": "^2.4.0" + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", - "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", - "cpu": [ - "arm64" - ], + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", - "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", - "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "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" + "engines": { + "node": ">=12" }, "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "4.1.11", - "vitest": "4.1.11" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/@vitest/expect": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", - "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" }, "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@vitest/mocker": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", - "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.11", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } + "engines": { + "node": ">= 14" } }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", - "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.1.0" + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">= 14" } }, - "node_modules/@vitest/runner": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", - "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "node_modules/archiver-utils/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "dev": true, "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.11", - "pathe": "^2.0.3" + "engines": { + "node": ">=8" }, "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@vitest/snapshot": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", - "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.11", - "@vitest/utils": "4.1.11", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "safer-buffer": "~2.1.0" } }, - "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/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=12" } }, - "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/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": { - "@vitest/pretty-format": "4.1.11", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" } }, - "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==", + "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", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-lock": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", + "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==", + "dev": true, + "license": "MIT" + }, + "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": { - "@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" + "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", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", "engines": { - "node": ">= 22" + "node": "18 || 20 || >=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==", + "node_modules/bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", "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" + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } } }, - "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" - ], + "node_modules/bare-fs": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.2.tgz", + "integrity": "sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==", "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": "SEE LICENSE IN LICENSE.txt", - "optional": true, - "os": [ - "win32" - ] - }, - "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", - "engines": { - "node": ">=18" - } - }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "dev": true, - "license": "MIT", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "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", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/archiver": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", - "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "archiver-utils": "^5.0.2", - "async": "^3.2.4", - "buffer-crc32": "^1.0.0", - "readable-stream": "^4.0.0", - "readdir-glob": "^1.1.2", - "tar-stream": "^3.0.0", - "zip-stream": "^6.0.1" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/archiver-utils": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", - "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "glob": "^10.0.0", - "graceful-fs": "^4.2.0", - "is-stream": "^2.0.1", - "lazystream": "^1.0.0", - "lodash": "^4.17.15", - "normalize-path": "^3.0.0", - "readable-stream": "^4.0.0" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/archiver-utils/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array-includes": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", - "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.0", - "es-object-atoms": "^1.1.1", - "get-intrinsic": "^1.3.0", - "is-string": "^1.1.1", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.findlast": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", - "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flat": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", - "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", - "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.tosorted": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", - "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.3", - "es-errors": "^1.3.0", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", - "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "is-array-buffer": "^3.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/asn1": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", - "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": "~2.1.0" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "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", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "dev": true, - "license": "MIT" - }, - "node_modules/async-function": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", - "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/async-lock": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", - "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "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", - "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", - "dev": true, - "license": "Apache-2.0", - "peerDependencies": { - "react-native-b4a": "*" - }, - "peerDependenciesMeta": { - "react-native-b4a": { - "optional": true - } - } - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/bare-events": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", - "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", - "dev": true, - "license": "Apache-2.0", - "peerDependencies": { - "bare-abort-controller": "*" - }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - } - } - }, - "node_modules/bare-fs": { - "version": "4.7.2", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.2.tgz", - "integrity": "sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "bare-events": "^2.5.4", - "bare-path": "^3.0.0", - "bare-stream": "^2.6.4", - "bare-url": "^2.2.2", - "fast-fifo": "^1.3.2" - }, - "engines": { - "bare": ">=1.16.0" - }, - "peerDependencies": { - "bare-buffer": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - } - } - }, - "node_modules/bare-os": { - "version": "3.9.1", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.1.tgz", - "integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "bare": ">=1.14.0" - } - }, - "node_modules/bare-path": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.1.tgz", - "integrity": "sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "bare-os": "^3.0.1" - } - }, - "node_modules/bare-stream": { - "version": "2.13.3", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", - "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "b4a": "^1.8.1", - "streamx": "^2.25.0", - "teex": "^1.0.1" - }, - "peerDependencies": { - "bare-abort-controller": "*", - "bare-buffer": "*", - "bare-events": "*" - }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - }, - "bare-buffer": { - "optional": true - }, - "bare-events": { - "optional": true - } - } - }, - "node_modules/bare-url": { - "version": "2.4.5", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.5.tgz", - "integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "bare-path": "^3.0.0" - } - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/bcrypt-pbkdf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", - "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "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", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/bl/node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/bl/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "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", - "integrity": "sha512-ufjMy1MaaZxkQF7f2wY6EL6TnRMSFr/r9eBhp0QvS41UHXW77aQVt65+dn2MmVMlHdyvZyOMNYXO2qYpgX6bQA==", - "license": "MIT", - "workspaces": [ - "example" - ], - "dependencies": { - "bpmn-moddle": "^10.0.0", - "min-dash": "^5.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/bpmn-moddle": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/bpmn-moddle/-/bpmn-moddle-10.2.0.tgz", - "integrity": "sha512-rjPr+hvh43DH9BXUHnXHVNcILYJbZ4rJdYY0GRV7ve99MURNjpIp/mCL8UJSz923uXJ4Doa/bEgBEsG01fPoYQ==", - "license": "MIT", - "dependencies": { - "min-dash": "^5.1.0", - "moddle": "^8.2.1", - "moddle-xml": "^12.2.0" - }, - "engines": { - "node": ">= 20.12" - } - }, - "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/buffer-crc32": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", - "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", - "dev": true, - "license": "MIT", - "engines": { - "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", - "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", - "dev": true, - "optional": true, - "engines": { - "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", - "integrity": "sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/call-bind": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", - "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "get-intrinsic": "^1.3.0", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chevrotain": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz", - "integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==", - "license": "Apache-2.0", - "dependencies": { - "@chevrotain/cst-dts-gen": "12.0.0", - "@chevrotain/gast": "12.0.0", - "@chevrotain/regexp-to-ast": "12.0.0", - "@chevrotain/types": "12.0.0", - "@chevrotain/utils": "12.0.0" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/chevrotain-allstar": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.4.3.tgz", - "integrity": "sha512-2X4mkroolSMKqW+H22pyPMUVDqYZzPhephTmg/NODKb1IGYPHfxfhcW0EjS7wcPJNbze2i4vBWT7zT5FKF2lrQ==", - "license": "MIT", - "dependencies": { - "lodash-es": "^4.18.1" - }, - "peerDependencies": { - "chevrotain": "^12.0.0" - } - }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "dev": true, - "license": "ISC" - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "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", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/commander": { - "version": "15.0.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", - "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", - "license": "MIT", - "engines": { - "node": ">=22.12.0" - } - }, - "node_modules/comment-parser": { - "version": "1.4.7", - "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.7.tgz", - "integrity": "sha512-0h+uSNtQGW3D98eQt3jJ8L06Fves8hncB4V/PKdw/Qb8Hnk19VaKuTr55UNRYiSoVa7WwrFls+rh3ux9agmkeQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/compress-commons": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", - "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "crc-32": "^1.2.0", - "crc32-stream": "^6.0.0", - "is-stream": "^2.0.1", - "normalize-path": "^3.0.0", - "readable-stream": "^4.0.0" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/compress-commons/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/concurrently": { - "version": "10.0.5", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.5.tgz", - "integrity": "sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "5.6.2", - "rxjs": "7.8.2", - "shell-quote": "1.9.0", - "supports-color": "10.2.2", - "tree-kill": "1.2.2", - "yargs": "18.0.0" - }, - "bin": { - "conc": "dist/bin/index.js", - "concurrently": "dist/bin/index.js" - }, - "engines": { - "node": ">=22" - }, - "funding": { - "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" - } - }, - "node_modules/concurrently/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/concurrently/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/concurrently/node_modules/cliui": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", - "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/concurrently/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, - "node_modules/concurrently/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/concurrently/node_modules/supports-color": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", - "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/concurrently/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/concurrently/node_modules/yargs": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", - "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^9.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "string-width": "^7.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^22.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, - "node_modules/concurrently/node_modules/yargs-parser": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", - "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/cpu-features": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", - "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "dependencies": { - "buildcheck": "~0.0.6", - "nan": "^2.19.0" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/crc-32": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", - "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "crc32": "bin/crc32.njs" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/crc32-stream": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", - "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", - "dev": true, - "license": "MIT", - "dependencies": { - "crc-32": "^1.2.0", - "readable-stream": "^4.0.0" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/data-view-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", - "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/data-view-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", - "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/inspect-js" - } - }, - "node_modules/data-view-byte-offset": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", - "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "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", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "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", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/docker-compose": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/docker-compose/-/docker-compose-1.4.2.tgz", - "integrity": "sha512-rPHigTKGaEHpkUmfd69QgaOp+Os5vGJwG/Ry8lcr8W/382AmI+z/D7qoa9BybKIkqNppaIbs8RYeHSevdQjWww==", - "dev": true, - "license": "MIT", - "dependencies": { - "yaml": "^2.2.2" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/docker-modem": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.7.tgz", - "integrity": "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "debug": "^4.1.1", - "readable-stream": "^3.5.0", - "split-ca": "^1.0.1", - "ssh2": "^1.15.0" - }, - "engines": { - "node": ">= 8.0" - } - }, - "node_modules/docker-modem/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/dockerode": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-5.0.1.tgz", - "integrity": "sha512-avsq/xk4YPIrn0CgleX5bjT9Y8IT1p9PxrNQ++RBQ2WEyFfHCTDsT9kmyxz+H/axnjAwg8wJWEIuPGOUuNupiA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@balena/dockerignore": "^1.0.2", - "@grpc/grpc-js": "^1.11.1", - "@grpc/proto-loader": "^0.7.13", - "docker-modem": "^5.0.7", - "protobufjs": "^7.3.2", - "tar-fs": "^2.1.4" - }, - "engines": { - "node": ">= 14.17" - } - }, - "node_modules/dockerode/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/dockerode/node_modules/tar-fs": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", - "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/dockerode/node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "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", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "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", - "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.2", - "arraybuffer.prototype.slice": "^1.0.4", - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "data-view-buffer": "^1.0.2", - "data-view-byte-length": "^1.0.2", - "data-view-byte-offset": "^1.0.1", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-set-tostringtag": "^2.1.0", - "es-to-primitive": "^1.3.0", - "function.prototype.name": "^1.1.8", - "get-intrinsic": "^1.3.0", - "get-proto": "^1.0.1", - "get-symbol-description": "^1.1.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "internal-slot": "^1.1.0", - "is-array-buffer": "^3.0.5", - "is-callable": "^1.2.7", - "is-data-view": "^1.0.2", - "is-negative-zero": "^2.0.3", - "is-regex": "^1.2.1", - "is-set": "^2.0.3", - "is-shared-array-buffer": "^1.0.4", - "is-string": "^1.1.1", - "is-typed-array": "^1.1.15", - "is-weakref": "^1.1.1", - "math-intrinsics": "^1.1.0", - "object-inspect": "^1.13.4", - "object-keys": "^1.1.1", - "object.assign": "^4.1.7", - "own-keys": "^1.0.1", - "regexp.prototype.flags": "^1.5.4", - "safe-array-concat": "^1.1.3", - "safe-push-apply": "^1.0.0", - "safe-regex-test": "^1.1.0", - "set-proto": "^1.0.0", - "stop-iteration-iterator": "^1.1.0", - "string.prototype.trim": "^1.2.10", - "string.prototype.trimend": "^1.0.9", - "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.3", - "typed-array-byte-length": "^1.0.3", - "typed-array-byte-offset": "^1.0.4", - "typed-array-length": "^1.0.7", - "unbox-primitive": "^1.1.0", - "which-typed-array": "^1.1.19" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-iterator-helpers": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", - "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", - "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "call-bind": "^1.0.9", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.2", - "es-errors": "^1.3.0", - "es-set-tostringtag": "^2.1.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.3.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "internal-slot": "^1.1.0", - "iterator.prototype": "^1.1.5", - "math-intrinsics": "^1.1.0" + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" }, "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" + "bare": ">=1.16.0" }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" + "peerDependencies": { + "bare-buffer": "*" }, - "engines": { - "node": ">= 0.4" + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } } }, - "node_modules/es-shim-unscopables": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", - "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "node_modules/bare-os": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.1.tgz", + "integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==", "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, + "license": "Apache-2.0", "engines": { - "node": ">= 0.4" + "bare": ">=1.14.0" } }, - "node_modules/es-to-primitive": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", - "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "node_modules/bare-path": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.1.tgz", + "integrity": "sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "is-callable": "^1.2.7", - "is-date-object": "^1.0.5", - "is-symbol": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "bare-os": "^3.0.1" } }, - "node_modules/esbuild": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", - "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "node_modules/bare-stream": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" }, - "engines": { - "node": ">=18" + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.2", - "@esbuild/android-arm": "0.28.2", - "@esbuild/android-arm64": "0.28.2", - "@esbuild/android-x64": "0.28.2", - "@esbuild/darwin-arm64": "0.28.2", - "@esbuild/darwin-x64": "0.28.2", - "@esbuild/freebsd-arm64": "0.28.2", - "@esbuild/freebsd-x64": "0.28.2", - "@esbuild/linux-arm": "0.28.2", - "@esbuild/linux-arm64": "0.28.2", - "@esbuild/linux-ia32": "0.28.2", - "@esbuild/linux-loong64": "0.28.2", - "@esbuild/linux-mips64el": "0.28.2", - "@esbuild/linux-ppc64": "0.28.2", - "@esbuild/linux-riscv64": "0.28.2", - "@esbuild/linux-s390x": "0.28.2", - "@esbuild/linux-x64": "0.28.2", - "@esbuild/netbsd-arm64": "0.28.2", - "@esbuild/netbsd-x64": "0.28.2", - "@esbuild/openbsd-arm64": "0.28.2", - "@esbuild/openbsd-x64": "0.28.2", - "@esbuild/openharmony-arm64": "0.28.2", - "@esbuild/sunos-x64": "0.28.2", - "@esbuild/win32-arm64": "0.28.2", - "@esbuild/win32-ia32": "0.28.2", - "@esbuild/win32-x64": "0.28.2" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } } }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "node_modules/bare-url": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.5.tgz", + "integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" } }, - "node_modules/eslint": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", - "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.2", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "9.39.4", - "@eslint/plugin-kit": "^0.4.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.5", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" } - } + ], + "license": "MIT" }, - "node_modules/eslint-config-prettier": { - "version": "10.1.8", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", - "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", "dev": true, - "license": "MIT", - "bin": { - "eslint-config-prettier": "bin/cli.js" - }, - "funding": { - "url": "https://opencollective.com/eslint-config-prettier" - }, - "peerDependencies": { - "eslint": ">=7.0.0" + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" } }, - "node_modules/eslint-import-context": { - "version": "0.1.9", - "resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.9.tgz", - "integrity": "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==", + "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": "MIT", + "license": "Artistic-2.0", "dependencies": { - "get-tsconfig": "^4.10.1", - "stable-hash-x": "^0.2.0" + "editions": "^6.21.0" }, "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + "node": ">=4" }, "funding": { - "url": "https://opencollective.com/eslint-import-context" - }, - "peerDependencies": { - "unrs-resolver": "^1.0.0" - }, - "peerDependenciesMeta": { - "unrs-resolver": { - "optional": true - } + "url": "https://bevry.me/fund" } }, - "node_modules/eslint-plugin-bpmn-io": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-bpmn-io/-/eslint-plugin-bpmn-io-2.3.1.tgz", - "integrity": "sha512-Y4CZcyLVFc5PbZpJxs91S3nZSOCLYNfC2JZQ5MWyGdU/1TxJPG0RFcv58HNxqQP7jKIf85TflXvNCvJX4dPClQ==", + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "dev": true, "license": "MIT", - "workspaces": [ - "test", - "test/esm" - ], "dependencies": { - "eslint-plugin-import-x": "^4.16.2", - "eslint-plugin-mocha": "^11.3.0", - "eslint-plugin-react": "^7.37.5", - "globals": "^17.6.0" - }, - "peerDependencies": { - "eslint": "^9" + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" } }, - "node_modules/eslint-plugin-bpmn-io/node_modules/globals": { - "version": "17.7.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", - "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" } }, - "node_modules/eslint-plugin-import-x": { - "version": "4.17.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-import-x/-/eslint-plugin-import-x-4.17.1.tgz", - "integrity": "sha512-4cdstYkKCyjumM2Q9NSI03K8D2a9F4Ssz33K2lv2hQa4KmR9jPLwk3uWGtNvclfqBrPGfGuMBwsGMbe6dMRbfg==", + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "^8.56.0", - "comment-parser": "^1.4.1", - "debug": "^4.4.1", - "eslint-import-context": "^0.1.9", - "is-glob": "^4.0.3", - "minimatch": "^9.0.3 || ^10.1.2", - "semver": "^7.7.2", - "stable-hash-x": "^0.2.0", - "unrs-resolver": "^1.9.2" + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint-plugin-import-x" - }, - "peerDependencies": { - "@typescript-eslint/utils": "^8.56.0", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "eslint-import-resolver-node": "*" - }, - "peerDependenciesMeta": { - "@typescript-eslint/utils": { - "optional": true - }, - "eslint-import-resolver-node": { - "optional": true - } + "node": ">= 6" } }, - "node_modules/eslint-plugin-mocha": { - "version": "11.3.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-mocha/-/eslint-plugin-mocha-11.3.0.tgz", - "integrity": "sha512-anENwrIwmdvunmmssjMn5a4nTd+mYMkqBlwjksxOECcIThLNhefWJIiTWY7pY/arMQFjNwHQjVOZb6pQ9PrLjg==", + "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", + "integrity": "sha512-ufjMy1MaaZxkQF7f2wY6EL6TnRMSFr/r9eBhp0QvS41UHXW77aQVt65+dn2MmVMlHdyvZyOMNYXO2qYpgX6bQA==", "license": "MIT", + "workspaces": [ + "example" + ], "dependencies": { - "@eslint-community/eslint-utils": "^4.4.1", - "globals": "^15.14.0" + "bpmn-moddle": "^10.0.0", + "min-dash": "^5.0.0" }, - "peerDependencies": { - "eslint": ">=9.0.0" + "engines": { + "node": ">= 18" } }, - "node_modules/eslint-plugin-mocha/node_modules/globals": { - "version": "15.15.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", - "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", - "dev": true, + "node_modules/bpmn-moddle": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/bpmn-moddle/-/bpmn-moddle-10.3.1.tgz", + "integrity": "sha512-xjJ8GzseRkjjM0MX/rODVB6iv+rhGLt8FCHJIovLAjJsDEAUjITfyiJZYczJ4pp3wmesbY3sQR7eRT+q5uroVA==", "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "dependencies": { + "min-dash": "^5.1.0", + "moddle": "^8.2.1", + "moddle-xml": "^12.3.1" } }, - "node_modules/eslint-plugin-react": { - "version": "7.37.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", - "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", - "dev": true, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "license": "MIT", "dependencies": { - "array-includes": "^3.1.8", - "array.prototype.findlast": "^1.2.5", - "array.prototype.flatmap": "^1.3.3", - "array.prototype.tosorted": "^1.1.4", - "doctrine": "^2.1.0", - "es-iterator-helpers": "^1.2.1", - "estraverse": "^5.3.0", - "hasown": "^2.0.2", - "jsx-ast-utils": "^2.4.1 || ^3.0.0", - "minimatch": "^3.1.2", - "object.entries": "^1.1.9", - "object.fromentries": "^2.0.8", - "object.values": "^1.2.1", - "prop-types": "^15.8.1", - "resolve": "^2.0.0-next.5", - "semver": "^6.3.1", - "string.prototype.matchall": "^4.0.12", - "string.prototype.repeat": "^1.0.0" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + "node": "20 || >=22" } }, - "node_modules/eslint-plugin-react/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/eslint-plugin-react/node_modules/brace-expansion": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", - "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" } }, - "node_modules/eslint-plugin-react/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, + "license": "MIT", "engines": { - "node": "*" + "node": ">=8.0.0" } }, - "node_modules/eslint-plugin-react/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "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": "ISC", - "bin": { - "semver": "bin/semver.js" + "license": "BSD-3-Clause" + }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "dev": true, + "optional": true, + "engines": { + "node": ">=10.0.0" } }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "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": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" + "run-applescript": "^7.0.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "node_modules/byline": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/byline/-/byline-5.0.0.tgz", + "integrity": "sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=0.10.0" } }, - "node_modules/eslint/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", - "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" } }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">= 0.4" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, - "license": "ISC", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chevrotain": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-13.2.0.tgz", + "integrity": "sha512-GTouk5YzgBxlTPCaDn5q0q2EJKkrYmnPF+HmF8v0Qo4r3iyPG7HJivkN1wthCXZcm3sIcZFpGTRovLPNdJNRCA==", + "license": "Apache-2.0", "dependencies": { - "brace-expansion": "^1.1.7" + "@chevrotain/cst-dts-gen": "13.2.0", + "@chevrotain/gast": "13.2.0", + "@chevrotain/regexp-to-ast": "13.2.0", + "@chevrotain/types": "13.2.0", + "@chevrotain/utils": "13.2.0" }, "engines": { - "node": "*" + "node": ">=22.0.0" } }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "node_modules/chevrotain-allstar": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.5.0.tgz", + "integrity": "sha512-KV7jVd28Qp4Kg9Ovhwlx5vyucoQxqxB22DLpxqRR2YDsHUHirr/NHAHx5bYk+bhJCD88xrLvOrfjlrrZRVa2Mg==", + "license": "MIT", + "dependencies": { + "lodash-es": "^4.18.1" + }, + "peerDependencies": { + "chevrotain": "^13.0.0" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "dev": true, - "license": "BSD-2-Clause", + "license": "ISC" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=12" } }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=8" } }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } + "license": "MIT" }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "estraverse": "^5.2.0" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">=4.0" + "node": ">=8" } }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, "engines": { - "node": ">=4.0" + "node": ">=8" } }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "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": ">=6" + "node": ">=16" } }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, "engines": { - "node": ">=0.8.x" + "node": ">=7.0.0" } }, - "node_modules/events-universal": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", - "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "bare-events": "^2.7.0" - } + "license": "MIT" }, - "node_modules/execa": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", - "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", - "dev": true, + "node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", "license": "MIT", - "dependencies": { - "cross-spawn": "^6.0.0", - "get-stream": "^4.0.0", - "is-stream": "^1.1.0", - "npm-run-path": "^2.0.0", - "p-finally": "^1.0.0", - "signal-exit": "^3.0.0", - "strip-eof": "^1.0.0" - }, "engines": { - "node": ">=6" + "node": ">=22.12.0" } }, - "node_modules/execa/node_modules/cross-spawn": { - "version": "6.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", - "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", "dev": true, "license": "MIT", "dependencies": { - "nice-try": "^1.0.4", - "path-key": "^2.0.1", - "semver": "^5.5.0", - "shebang-command": "^1.2.0", - "which": "^1.2.9" + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" }, "engines": { - "node": ">=4.8" + "node": ">= 14" } }, - "node_modules/execa/node_modules/path-key": { + "node_modules/compress-commons/node_modules/is-stream": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", - "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "dev": true, "license": "MIT", "engines": { - "node": ">=4" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/execa/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "node_modules/concurrently": { + "version": "10.0.6", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.6.tgz", + "integrity": "sha512-qgICXXFp2/3nAebRLmxcINKEXM1mLK3ij/y31P02VTs/KIkddIeC05n73DxOXsXMB9LFSqNU1/na3lDlOhcbIw==", "dev": true, - "license": "ISC", + "license": "MIT", + "dependencies": { + "chalk": "5.6.2", + "rxjs": "7.8.2", + "shell-quote": "1.12.0", + "supports-color": "10.2.2", + "tree-kill": "1.2.2", + "yargs": "18.0.0" + }, "bin": { - "semver": "bin/semver" + "conc": "dist/bin/index.js", + "concurrently": "dist/bin/index.js" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, - "node_modules/execa/node_modules/shebang-command": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", - "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "node_modules/concurrently/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", - "dependencies": { - "shebang-regex": "^1.0.0" - }, "engines": { - "node": ">=0.10.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/execa/node_modules/shebang-regex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", - "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "node_modules/concurrently/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/execa/node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "node_modules/concurrently/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", "dev": true, "license": "ISC", "dependencies": { - "isexe": "^2.0.0" + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, - "bin": { - "which": "bin/which" - } - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", "engines": { - "node": ">=12.0.0" + "node": ">=20" } }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "node_modules/concurrently/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "dev": true, "license": "MIT" }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "node_modules/concurrently/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/concurrently/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "node_modules/concurrently/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "license": "MIT", "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=8.6.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "node_modules/concurrently/node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "is-glob": "^4.0.1" + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" }, "engines": { - "node": ">= 6" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "node_modules/concurrently/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", "dev": true, - "license": "MIT" + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "dev": true, "license": "MIT" }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", "dev": true, - "license": "ISC", + "hasInstallScript": true, + "optional": true, "dependencies": { - "reusify": "^1.0.4" + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" } }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" }, "engines": { - "node": ">=16.0.0" + "node": ">=0.8" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", "dev": true, "license": "MIT", "dependencies": { - "to-regex-range": "^5.0.1" + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" }, "engines": { - "node": ">=8" + "node": ">= 14" } }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 8" } }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" + "ms": "^2.1.3" }, "engines": { - "node": ">=16" + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, - "license": "ISC" + "license": "MIT" }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "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": { - "is-callable": "^1.2.7" + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" }, "engines": { - "node": ">= 0.4" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "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": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, + "license": "MIT", "engines": { - "node": ">=14" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "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": "ISC", + "license": "MIT", "engines": { - "node": ">=14" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "dev": true, - "license": "MIT" - }, - "node_modules/fs-extra": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", - "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, + "license": "Apache-2.0", + "peer": true, "engines": { - "node": ">=14.14" + "node": ">=8" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/docker-compose": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/docker-compose/-/docker-compose-1.5.0.tgz", + "integrity": "sha512-O+eD6c63+BJORUWddXA7uAlI6H1KLDPd/aS14k73nXYpFliM48C3xrrKB3sXsR1Fp/rHUZQe7+fJ+QA7+jepyw==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "yaml": "^2.2.2" + }, "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 6.0.0" } }, - "node_modules/function.prototype.name": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", - "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "node_modules/docker-modem": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.7.tgz", + "integrity": "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "functions-have-names": "^1.2.3", - "hasown": "^2.0.2", - "is-callable": "^1.2.7" + "debug": "^4.1.1", + "readable-stream": "^3.5.0", + "split-ca": "^1.0.1", + "ssh2": "^1.15.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 8.0" } }, - "node_modules/generator-function": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", - "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "node_modules/docker-modem/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, "engines": { - "node": ">= 0.4" + "node": ">= 6" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "node_modules/dockerode": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-5.0.1.tgz", + "integrity": "sha512-avsq/xk4YPIrn0CgleX5bjT9Y8IT1p9PxrNQ++RBQ2WEyFfHCTDsT9kmyxz+H/axnjAwg8wJWEIuPGOUuNupiA==", "dev": true, - "license": "ISC", + "license": "Apache-2.0", + "dependencies": { + "@balena/dockerignore": "^1.0.2", + "@grpc/grpc-js": "^1.11.1", + "@grpc/proto-loader": "^0.7.13", + "docker-modem": "^5.0.7", + "protobufjs": "^7.3.2", + "tar-fs": "^2.1.4" + }, "engines": { - "node": "6.* || 8.* || >= 10.*" + "node": ">= 14.17" } }, - "node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "node_modules/dockerode/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">= 6" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "node_modules/dockerode/node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" } }, - "node_modules/get-port": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", - "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==", + "node_modules/dockerode/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">=8" + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=6" } }, - "node_modules/get-proto": { + "node_modules/dunder-proto": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "dev": true, "license": "MIT", "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" }, "engines": { "node": ">= 0.4" } }, - "node_modules/get-stream": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", - "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true, - "license": "MIT", + "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": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=6" + "safe-buffer": "^5.0.1" } }, - "node_modules/get-symbol-description": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", - "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "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": "MIT", + "license": "Artistic-2.0", "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6" + "version-range": "^4.15.0" }, "engines": { - "node": ">= 0.4" + "ecmascript": ">= es5", + "node": ">=4" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://bevry.me/fund" } }, - "node_modules/get-tsconfig": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", - "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "dev": true, "license": "MIT", "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + "once": "^1.4.0" } }, - "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, + "license": "MIT", "engines": { - "node": ">=10.13.0" + "node": ">= 0.4" } }, - "node_modules/glob/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", - "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" } }, - "node_modules/glob/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=18" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" } }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "node_modules/eslint": { + "version": "10.13.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.13.0.tgz", + "integrity": "sha512-UohDjD4zSzEwhf58M3O7vc/ycc8O1I7dNlhtD0WxE8Px8EHNJKtUwzBqrzeGhisRuv2ag4sAYswszCJRgKXYUg==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" }, "engines": { - "node": ">= 0.4" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.4" + "bin": { + "eslint-config-prettier": "bin/cli.js" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/has-bigints": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, "engines": { - "node": ">= 0.4" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/eslint" } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "engines": { - "node": ">=8" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/eslint" } }, - "node_modules/has-proto": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", - "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "dunder-proto": "^1.0.0" + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": ">= 0.4" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/eslint" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">=0.10" } }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "has-symbols": "^1.0.3" + "estraverse": "^5.2.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=4.0" } }, - "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, + "license": "BSD-2-Clause", "engines": { - "node": ">= 0.4" + "node": ">=4.0" } }, - "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==", + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "lru-cache": "^6.0.0" - }, - "engines": { - "node": ">=10" + "@types/estree": "^1.0.0" } }, - "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==", + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, + "license": "BSD-2-Clause", "engines": { - "node": ">=10" + "node": ">=0.10.0" } }, - "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==", + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=6" + } }, - "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==", + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "dev": true, "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, "engines": { - "node": ">= 14" + "node": ">=0.8.x" } }, - "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==", + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, + "bare-events": "^2.7.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">= 14" + "node": ">=12.0.0" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" + "license": "MIT" }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 4" + "dependencies": { + "flat-cache": "^6.1.23" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" }, "engines": { - "node": ">=6" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/internal-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" } }, - "node_modules/interpret": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", - "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.10" - } + "license": "ISC" }, - "node_modules/is-array-buffer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" }, "engines": { - "node": ">= 0.4" + "node": ">=14" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/is-async-function": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", - "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, - "license": "MIT", - "dependencies": { - "async-function": "^1.0.0", - "call-bound": "^1.0.3", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, + "license": "ISC", "engines": { - "node": ">= 0.4" + "node": ">=14" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/is-bigint": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz", + "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", "dev": true, "license": "MIT", "dependencies": { - "has-bigints": "^1.0.2" + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=14.14" } }, - "node_modules/is-boolean-object": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, + "hasInstallScript": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, + "optional": true, + "os": [ + "darwin" + ], + "peer": true, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.4" - }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-core-module": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", - "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.3" - }, + "license": "ISC", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/is-data-view": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", - "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "is-typed-array": "^1.1.13" - }, "engines": { - "node": ">= 0.4" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-date-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -6184,114 +4142,118 @@ "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==", + "node_modules/get-port": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", + "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==", "dev": true, "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=8" }, "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", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "dev": true, "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" } }, - "node_modules/is-finalizationregistry": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", - "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "call-bound": "^1.0.3" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" }, - "engines": { - "node": ">= 0.4" + "bin": { + "glob": "dist/esm/bin.mjs" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, - "license": "MIT", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, "engines": { - "node": ">=8" + "node": ">=10.13.0" } }, - "node_modules/is-generator-function": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", - "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.4", - "generator-function": "^2.0.0", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "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": { + "balanced-match": "^1.0.0" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "is-extglob": "^2.1.1" + "brace-expansion": "^2.0.2" }, "engines": { - "node": ">=0.10.0" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "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==", + "node_modules/globals": { + "version": "17.13.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz", + "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", "dev": true, "license": "MIT", - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, "engines": { - "node": ">=14.16" + "node": ">=18" }, "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", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "dev": true, "license": "MIT", "engines": { @@ -6301,10 +4263,17 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-negative-zero": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "dev": true, "license": "MIT", "engines": { @@ -6314,186 +4283,207 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", "dev": true, "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, "engines": { - "node": ">=0.12.0" + "node": ">=20" } }, - "node_modules/is-number-object": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" + "function-bind": "^1.1.2" }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", "dev": true, - "license": "MIT", + "license": "MIT" + }, + "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": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" + "lru-cache": "^6.0.0" }, "engines": { - "node": ">= 0.4" + "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" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">=10" } }, - "node_modules/is-set": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "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", - "engines": { - "node": ">= 0.4" + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">= 14" } }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "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": { - "call-bound": "^1.0.3" + "agent-base": "^7.1.2", + "debug": "4" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 14" } }, - "node_modules/is-stream": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", - "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">= 4" } }, - "node_modules/is-string": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.8.19" } }, - "node_modules/is-symbol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "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", - "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" + "bin": { + "is-docker": "cli.js" }, "engines": { - "node": ">= 0.4" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, "license": "MIT", - "dependencies": { - "which-typed-array": "^1.1.16" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.10.0" } }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8" } }, - "node_modules/is-weakref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", - "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3" + "is-extglob": "^2.1.1" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.10.0" } }, - "node_modules/is-weakset": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "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": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" }, "engines": { - "node": ">= 0.4" + "node": ">=14.16" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/is-wsl": { @@ -6512,13 +4502,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, - "license": "MIT" - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -6526,45 +4509,6 @@ "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", @@ -6583,24 +4527,6 @@ "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", - "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.6", - "get-proto": "^1.0.0", - "has-symbols": "^1.1.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -6617,43 +4543,6 @@ "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", - "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -6721,22 +4610,6 @@ "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", - "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.flat": "^1.3.1", - "object.assign": "^4.1.4", - "object.values": "^1.1.6" - }, - "engines": { - "node": ">=4.0" - } - }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -6761,26 +4634,26 @@ } }, "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "dev": true, "license": "MIT", "dependencies": { - "json-buffer": "3.0.1" + "@keyv/serialize": "^1.1.1" } }, "node_modules/langium": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/langium/-/langium-4.3.1.tgz", - "integrity": "sha512-4lmkLHytLXD5pvyL0PKUj1LCNpgzv9s8luH+GbWXHG45QLmO6PlFXrCUghDN5BfcFTlaQyXpXiaSl8A28Xep8w==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/langium/-/langium-4.4.0.tgz", + "integrity": "sha512-VFIFtohpsmJeQ+LkaeuUNtyvkklecklI0FSLxFS8JfiGABE9XCpUJwYUNnLk6C2aOyk0w99tNlvd0pmbJ2AW/w==", "license": "MIT", "dependencies": { - "@chevrotain/regexp-to-ast": "~12.0.0", - "chevrotain": "~12.0.0", - "chevrotain-allstar": "~0.4.3", - "vscode-languageserver": "~10.0.1", - "vscode-languageserver-protocol": "~3.18.1", + "@chevrotain/regexp-to-ast": "~13.2.0", + "chevrotain": "~13.2.0", + "chevrotain-allstar": "~0.5.0", + "vscode-languageserver": "~10.1.0", + "vscode-languageserver-protocol": "~3.18.2", "vscode-languageserver-textdocument": "~1.0.13", "vscode-languageserver-types": "~3.18.0", "vscode-uri": "~3.1.0" @@ -6791,18 +4664,18 @@ } }, "node_modules/langium-cli": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/langium-cli/-/langium-cli-4.3.0.tgz", - "integrity": "sha512-+TtgkhX8Z3exRBJvTFWV1semhWUCnWMhMP+ZKjpKIeyY6GgnQHehf3ZWyCvVL5T95uqgWZb77aoq9rhI2MPNTA==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/langium-cli/-/langium-cli-4.4.0.tgz", + "integrity": "sha512-CSqwKebp8qWa9Y5s1xgEl4qxGPH71Wf4yMXKPuwICoL2yRfVtUl2zpTwF4O5WhYOxnuSpf0rJrxzjQRK5whL7Q==", "dev": true, "license": "MIT", "dependencies": { "chalk": "~5.6.2", "commander": "~15.0.0", - "fs-extra": "~11.3.5", + "fs-extra": "~11.4.0", "jsonschema": "~1.5.0", - "langium": "~4.3.0", - "langium-railroad": "~4.3.0", + "langium": "~4.4.0", + "langium-railroad": "~4.4.0", "lodash": "~4.18.1" }, "bin": { @@ -6827,13 +4700,13 @@ } }, "node_modules/langium-railroad": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/langium-railroad/-/langium-railroad-4.3.0.tgz", - "integrity": "sha512-dbuv149MQe8XcgM48FagH22JBaqN0MvN9RRJKBdRQWDeHUYAASliiOtLdMfgOl3kV+nLx7idSc+LLdcBSduvHw==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/langium-railroad/-/langium-railroad-4.4.0.tgz", + "integrity": "sha512-5dZINWslRqUkQaVtn+45SdZyOZ2+LwBGrzWKbRkVf76IZPCRTBzbmNYaCFW09Nwl5dMmv+iQvwkUwViljN0hvg==", "dev": true, "license": "MIT", "dependencies": { - "langium": "~4.3.0", + "langium": "~4.4.0", "railroad-diagrams": "~1.0.0" } }, @@ -6910,6 +4783,7 @@ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", + "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -6947,6 +4821,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6968,6 +4843,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6989,6 +4865,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7010,6 +4887,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7031,6 +4909,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7052,6 +4931,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7073,6 +4953,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7094,6 +4975,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7115,6 +4997,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7136,6 +5019,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7157,6 +5041,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7243,13 +5128,6 @@ "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", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "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", @@ -7264,19 +5142,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -7285,13 +5150,13 @@ "license": "ISC" }, "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "@jridgewell/sourcemap-codec": "^1.6.0" } }, "node_modules/magicast": { @@ -7306,22 +5171,6 @@ "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", @@ -7345,30 +5194,6 @@ "node": ">= 0.4" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, "node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -7403,16 +5228,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -7420,291 +5235,122 @@ "dev": true, "license": "BlueOak-1.0.0", "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/mkdirp": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", - "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", - "dev": true, - "license": "MIT", - "bin": { - "mkdirp": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "dev": true, - "license": "MIT" - }, - "node_modules/moddle": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/moddle/-/moddle-8.2.1.tgz", - "integrity": "sha512-20qzO/ZpMGO9j9gdQgXk9bfGxb9wA3JKnGth/21YZKODLrZE4RW/uVMIL5pTskYm34Z61DucBEPkq93qByNsBQ==", - "license": "MIT", - "dependencies": { - "min-dash": "^5.1.0" - } - }, - "node_modules/moddle-xml": { - "version": "12.2.0", - "resolved": "https://registry.npmjs.org/moddle-xml/-/moddle-xml-12.2.0.tgz", - "integrity": "sha512-kqjdAod5n2v3IVJGudJKRUa1PU/p8GpIb0HbfbVXCppt/MEHtRs+Qi/Q3KTy31JbZCUF9L36mY4wUx7PqqMX4g==", - "license": "MIT", - "dependencies": { - "min-dash": "^5.1.0", - "saxen": "^11.1.0" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "moddle": ">= 6.2.0" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "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", - "integrity": "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/napi-postinstall": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", - "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", - "dev": true, - "license": "MIT", - "bin": { - "napi-postinstall": "lib/cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/napi-postinstall" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/nice-try": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", - "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-exports-info": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", - "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "array.prototype.flatmap": "^1.3.3", - "es-errors": "^1.3.0", - "object.entries": "^1.1.9", - "semver": "^6.3.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/node-exports-info/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/npm-run-path": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", - "integrity": "sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/npm-run-path/node_modules/path-key": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", - "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", "dev": true, "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, "engines": { - "node": ">= 0.4" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "dev": true, + "license": "MIT" + }, + "node_modules/moddle": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/moddle/-/moddle-8.2.1.tgz", + "integrity": "sha512-20qzO/ZpMGO9j9gdQgXk9bfGxb9wA3JKnGth/21YZKODLrZE4RW/uVMIL5pTskYm34Z61DucBEPkq93qByNsBQ==", "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "min-dash": "^5.1.0" } }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, + "node_modules/moddle-xml": { + "version": "12.3.1", + "resolved": "https://registry.npmjs.org/moddle-xml/-/moddle-xml-12.3.1.tgz", + "integrity": "sha512-XbAQDWCG2ZRch3odmQigJ4GwTqrOVBx0+JHEAgdw0U3MkBsWa9LAbO//S5RPdYMcRO87E63r/R/I3CoMef6ncA==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" + "min-dash": "^5.1.0", + "saxen": "^11.2.0" }, "engines": { - "node": ">= 0.4" + "node": ">= 18" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "moddle": ">= 6.2.0" } }, - "node_modules/object.entries": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", - "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "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", + "integrity": "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==", "dev": true, "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.1.1" + "optional": true + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" }, "engines": { - "node": ">= 0.4" + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/object.fromentries": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", - "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true, "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.10.0" } }, - "node_modules/object.values": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", - "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "dev": true, "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, "engines": { "node": ">= 0.4" }, @@ -7713,15 +5359,18 @@ } }, "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" ], - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } }, "node_modules/once": { "version": "1.4.0", @@ -7770,34 +5419,6 @@ "node": ">= 0.8.0" } }, - "node_modules/own-keys": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", - "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.2.6", - "object-keys": "^1.1.1", - "safe-push-apply": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/p-finally": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", - "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -7837,19 +5458,6 @@ "dev": true, "license": "BlueOak-1.0.0" }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -7883,13 +5491,6 @@ "node": ">=8" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, "node_modules/path-scurry": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", @@ -7907,13 +5508,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -7926,30 +5520,8 @@ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } + "license": "ISC", + "peer": true }, "node_modules/postcss": { "version": "8.5.26", @@ -7971,6 +5543,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", @@ -7991,9 +5564,9 @@ } }, "node_modules/prettier": { - "version": "3.9.6", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", - "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", + "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==", "dev": true, "license": "MIT", "bin": { @@ -8023,18 +5596,6 @@ "dev": true, "license": "MIT" }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, "node_modules/proper-lockfile": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", @@ -8110,6 +5671,26 @@ "node": ">=6" } }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, "node_modules/qs": { "version": "6.16.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", @@ -8127,27 +5708,6 @@ "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", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/railroad-diagrams": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", @@ -8155,13 +5715,6 @@ "dev": true, "license": "CC0-1.0" }, - "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, - "license": "MIT" - }, "node_modules/read": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", @@ -8211,103 +5764,25 @@ }, "node_modules/readdir-glob/node_modules/brace-expansion": { "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": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/readdir-glob/node_modules/minimatch": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", - "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/rechoir": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", - "integrity": "sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==", - "dev": true, - "dependencies": { - "resolve": "^1.1.6" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/rechoir/node_modules/resolve": { - "version": "1.22.12", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", - "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/reflect.getprototypeof": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", - "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.7", - "get-proto": "^1.0.1", - "which-builtin-type": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" } }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" + "brace-expansion": "^2.0.1" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=10" } }, "node_modules/require-directory": { @@ -8320,50 +5795,6 @@ "node": ">=0.10.0" } }, - "node_modules/resolve": { - "version": "2.0.0-next.7", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", - "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.2", - "node-exports-info": "^1.6.0", - "object-keys": "^1.1.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -8374,23 +5805,13 @@ "node": ">= 4" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rolldown": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz", "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@oxc-project/types": "=0.147.0", "@rolldown/pluginutils": "^1.0.0" @@ -8432,30 +5853,6 @@ "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", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -8466,26 +5863,6 @@ "tslib": "^2.1.0" } }, - "node_modules/safe-array-concat": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", - "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.9", - "call-bound": "^1.0.4", - "get-intrinsic": "^1.3.0", - "has-symbols": "^1.1.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">=0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -8507,41 +5884,6 @@ ], "license": "MIT" }, - "node_modules/safe-push-apply": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", - "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -8560,9 +5902,9 @@ } }, "node_modules/saxen": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/saxen/-/saxen-11.1.1.tgz", - "integrity": "sha512-J4BkmJFaM7VgE7pgkFGsNEcqqM3h7+Mz80vfLWFhx7uNOCOXIu6LLjQHYWNejdst3pf/3JUaBIG9+pkk1umlow==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/saxen/-/saxen-11.2.0.tgz", + "integrity": "sha512-2bG564AAJcm/3vyZC0Uhs/mACrH+4AHMaKHKAB2M5xMqomUZa27FQ7MYaQGFKVcJk/MVzSEQ34A8QZWW+eiWEw==", "license": "MIT", "engines": { "node": ">= 20.12" @@ -8580,55 +5922,6 @@ "node": ">=10" } }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-proto": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", - "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -8653,9 +5946,9 @@ } }, "node_modules/shell-quote": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", - "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz", + "integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==", "dev": true, "license": "MIT", "engines": { @@ -8665,42 +5958,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/shelljs": { - "version": "0.9.2", - "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.9.2.tgz", - "integrity": "sha512-S3I64fEiKgTZzKCC46zT/Ib9meqofLrQVbpSswtjFfAVDW+AZ54WTnAM/3/yENoxz/V1Cy6u3kiiEbQ4DNphvw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "execa": "^1.0.0", - "fast-glob": "^3.3.2", - "interpret": "^1.0.0", - "rechoir": "^0.6.2" - }, - "bin": { - "shjs": "bin/shjs" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/shx": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/shx/-/shx-0.4.0.tgz", - "integrity": "sha512-Z0KixSIlGPpijKgcH6oCMCbltPImvaKy0sGH8AkLRXw1KyzpKtaCTizP2xen+hNDqVF4xxgvA0KXSb9o4Q6hnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.8", - "shelljs": "^0.9.2" - }, - "bin": { - "shx": "lib/cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/side-channel": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", @@ -8777,13 +6034,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -8792,9 +6042,9 @@ "license": "ISC" }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -8848,44 +6098,13 @@ "nan": "^2.23.0" } }, - "node_modules/stable-hash-x": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", - "integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, "node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", "dev": true, "license": "MIT" }, - "node_modules/stop-iteration-iterator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "internal-slot": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/streamx": { "version": "2.28.0", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", @@ -8949,125 +6168,27 @@ "dev": true, "license": "MIT", "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string.prototype.matchall": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", - "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.6", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.6", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "internal-slot": "^1.1.0", - "regexp.prototype.flags": "^1.5.3", - "set-function-name": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.repeat": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", - "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.5" - } - }, - "node_modules/string.prototype.trim": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-data-property": "^1.1.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-object-atoms": "^1.0.0", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimend": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8" } }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", - "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" + "ansi-regex": "^5.0.1" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8" } }, "node_modules/strip-ansi": { @@ -9110,29 +6231,6 @@ "node": ">=8" } }, - "node_modules/strip-eof": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", - "integrity": "sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "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", @@ -9143,32 +6241,6 @@ "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", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/tar-fs": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz", @@ -9208,9 +6280,9 @@ } }, "node_modules/testcontainers": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-12.1.0.tgz", - "integrity": "sha512-YjDLqIITuhGLMnM10yhg3oV6lIG5IMpz1R1DPBZoOOks83q7i7IVpeSWRTiyl7roozjiyLmwIoLK/KY8OnZmIA==", + "version": "12.2.1", + "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-12.2.1.tgz", + "integrity": "sha512-JfbCyOvLdzatwgqUI3AvYCwRW0P5mCkKQVBPO8U5qSH1efC2kHvnfWmi9VHloy1rAFG2MEqWZObRH4bq0gU1kQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9220,7 +6292,7 @@ "async-lock": "^1.4.1", "byline": "^5.0.0", "debug": "^4.4.3", - "docker-compose": "^1.4.2", + "docker-compose": "^1.5.0", "dockerode": "^5.0.1", "get-port": "^5.1.1", "proper-lockfile": "^4.1.2", @@ -9228,7 +6300,7 @@ "ssh-remote-port-forward": "^1.0.4", "tar-fs": "^3.1.3", "tmp": "^0.2.7", - "undici": "^8.9.0" + "undici": "^8.11.2" }, "engines": { "node": ">= 22.22" @@ -9265,16 +6337,19 @@ } }, "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } }, "node_modules/tinyexec": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz", - "integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, "license": "MIT", "engines": { @@ -9330,9 +6405,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", - "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", "dev": true, "license": "MIT", "engines": { @@ -9349,19 +6424,6 @@ "node": ">=14.14" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -9422,84 +6484,6 @@ "node": ">= 0.8.0" } }, - "node_modules/typed-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/typed-array-byte-length": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", - "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-byte-offset": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", - "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.15", - "reflect.getprototypeof": "^1.0.9" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-length": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0", - "reflect.getprototypeof": "^1.0.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "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", @@ -9526,25 +6510,6 @@ "node": ">=14.17" } }, - "node_modules/unbox-primitive": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", - "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-bigints": "^1.0.2", - "has-symbols": "^1.1.0", - "which-boxed-primitive": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/underscore": { "version": "1.13.8", "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", @@ -9553,9 +6518,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "8.10.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.1.tgz", - "integrity": "sha512-YQ3WlbqjYMmNpdvDH64jAgLjxuAR9+649calDWhbshYaeQGO2bR4nI94ORJmwI3J9YhoKQnpyGOK+0zlWS5N5Q==", + "version": "8.12.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.2.tgz", + "integrity": "sha512-XtuDWVdqTLr3NAxm2ly9/hU6kUsJOgGh1RASzMBbsw6aWCOaQzHdW+QQDfdERjfJ2V5vL5pRPa4RetO9UfivDg==", "dev": true, "license": "MIT", "engines": { @@ -9563,9 +6528,9 @@ } }, "node_modules/undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", "dev": true, "license": "MIT" }, @@ -9579,44 +6544,6 @@ "node": ">= 10.0.0" } }, - "node_modules/unrs-resolver": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", - "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "napi-postinstall": "^0.3.4" - }, - "funding": { - "url": "https://opencollective.com/unrs-resolver" - }, - "optionalDependencies": { - "@unrs/resolver-binding-android-arm-eabi": "1.12.2", - "@unrs/resolver-binding-android-arm64": "1.12.2", - "@unrs/resolver-binding-darwin-arm64": "1.12.2", - "@unrs/resolver-binding-darwin-x64": "1.12.2", - "@unrs/resolver-binding-freebsd-x64": "1.12.2", - "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", - "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", - "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", - "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", - "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", - "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", - "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", - "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", - "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", - "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", - "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", - "@unrs/resolver-binding-linux-x64-musl": "1.12.2", - "@unrs/resolver-binding-openharmony-arm64": "1.12.2", - "@unrs/resolver-binding-wasm32-wasi": "1.12.2", - "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", - "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", - "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" - } - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -9660,6 +6587,7 @@ "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", @@ -9738,6 +6666,7 @@ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9746,38 +6675,31 @@ } }, "node_modules/vitest": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", - "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.11", - "@vitest/mocker": "4.1.11", - "@vitest/pretty-format": "4.1.11", - "@vitest/runner": "4.1.11", - "@vitest/snapshot": "4.1.11", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz", + "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.5", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -9785,16 +6707,16 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.11", - "@vitest/browser-preview": "4.1.11", - "@vitest/browser-webdriverio": "4.1.11", - "@vitest/coverage-istanbul": "4.1.11", - "@vitest/coverage-v8": "4.1.11", - "@vitest/ui": "4.1.11", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.5", + "@vitest/browser-preview": "5.0.5", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.5", + "@vitest/coverage-v8": "5.0.5", + "@vitest/ui": "5.0.5", "happy-dom": "*", "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -9836,9 +6758,9 @@ } }, "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -9853,88 +6775,63 @@ "link": true }, "node_modules/vscode-jsonrpc": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.2.tgz", - "integrity": "sha512-SbQSV9yRemARxeXw6LU5sS6Zq0e9/DgCCX5yelH263ZQWukbTk8EF8fjTrr1dziasf4GwlJbvTwFnTrnQFWZXQ==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.3.tgz", + "integrity": "sha512-qETYf9PCl1lVfwR+oBmMfc4vP2nqLNA35ABxorw3j06+xlKRgDVxbLgKm4Y/xPL0YWdeDjLoI4ImICIT3nB0zQ==", "license": "MIT", "engines": { "node": ">=14.0.0" } }, "node_modules/vscode-languageclient": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-10.1.1.tgz", - "integrity": "sha512-EbwIF+KUbwxD5jumQNpiBCv7BJM3Rierv4hcbw/p5jdsnwFFpR/B2VmwqfH4p5XsK46nkH3ugA3WtwJekHHJEw==", + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-10.1.2.tgz", + "integrity": "sha512-cOv5SMvtAhfVeo5m2r9L2rVHAD0J5CFOsmvH1WgNmPaI61VYjJNc3XUDLOKeFSDtWoAXIEoK6KetuBEL/3hErg==", "license": "MIT", "dependencies": { "minimatch": "^10.2.6", "semver": "^7.8.1", - "vscode-languageserver-protocol": "3.18.3", - "vscode-languageserver-textdocument": "1.0.14" + "vscode-languageserver-protocol": "3.18.4", + "vscode-languageserver-textdocument": "1.0.15" }, "engines": { "vscode": "^1.91.0" } }, "node_modules/vscode-languageserver": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-10.0.1.tgz", - "integrity": "sha512-hfERoZvpaHWzQa7t5mMIrHkEOhF7aTs1dpktem0TW/Xs4QZD7NtcHjIq/aE1CZJEj6Rb8LSGUkBhvTRDdpyGig==", + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-10.1.2.tgz", + "integrity": "sha512-pXPWde3RytdeBVEs6PulOOT5OpeXC5GkWRpcT11f5rNn41jviQsAYPyAqJ/0oVGETQLKPJtYbHNiMPlfLb0NTQ==", "license": "MIT", "dependencies": { - "vscode-languageserver-protocol": "3.18.1" + "vscode-languageserver-protocol": "3.18.4" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "node_modules/vscode-languageserver-protocol": { - "version": "3.18.3", - "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.3.tgz", - "integrity": "sha512-DF49+WeV5py4zO5hhobp60jjsDSK0lAqA0OuKBLBvp423HPWQcCbhZz3JgyfIewsEz2f8U+X75xNIFHdiXZm2w==", + "version": "3.18.4", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.4.tgz", + "integrity": "sha512-CCXbyr99dafRJzJH/0Ryk7ly5T8XPmSR0//OuF2w4m+9JiOin7zcAT5iET8GRE7Z9taFBejb3GbM0a0mbjdbrg==", "license": "MIT", "dependencies": { - "vscode-jsonrpc": "9.0.2", - "vscode-languageserver-types": "3.18.3" + "vscode-jsonrpc": "9.0.3", + "vscode-languageserver-types": "3.18.4" } }, - "node_modules/vscode-languageserver-protocol/node_modules/vscode-languageserver-types": { - "version": "3.18.3", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.3.tgz", - "integrity": "sha512-XIlzJ7Qp/jzSI1ds7/FwPAWrPeTZA7pAtlW4hdJ1J6xXWJL6dR9QYnDhJOdLzdKhUQ5Mm6mvUMw+3DcOQQasPw==", - "license": "MIT" - }, "node_modules/vscode-languageserver-textdocument": { - "version": "1.0.14", - "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.14.tgz", - "integrity": "sha512-EQyqJMi552E4ZTf46izQ4Fj6XquqxCySR3J5ZSD1SisMf6RfpeOWHxGBE8Gr6V0/3GHIGdAzDn8F8+1nTGCnoQ==", + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.15.tgz", + "integrity": "sha512-Gsfr9S8LU3S6885cS+NaleasJ0n3V5y/SOTqHCifQiXWFw3G9w9/YItDfwHrlCz+dSZWWZ8ilYVMcJe7Sz7Sxw==", "license": "MIT" }, "node_modules/vscode-languageserver-types": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.0.tgz", - "integrity": "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g==", + "version": "3.18.4", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.4.tgz", + "integrity": "sha512-nQsacoiijvl0fRf2q28uFDrAVwRhdCa3QveiF7/CX0r18mLR5FU/1WskSsCKOjmEbtteKUnY8x8Yzxop+k0zwg==", "license": "MIT" }, - "node_modules/vscode-languageserver/node_modules/vscode-jsonrpc": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.0.tgz", - "integrity": "sha512-+VvMmQPJhtvJ+8O+zu2JKIRiLxXF8NW7krWgyMGeOHrp4Cn23T5hc0v2LknNeopDOB70wghHAds7mKtcZ0I4Sg==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/vscode-languageserver/node_modules/vscode-languageserver-protocol": { - "version": "3.18.1", - "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.1.tgz", - "integrity": "sha512-RTiiVHdpxpYcJVI5sq6S5TLjQ4WDR/rBrIWru+kPXe6sGQ9PFQ3GamrTKLvPqbR4ylr1SoodhmcqbFII0WXVuw==", - "license": "MIT", - "dependencies": { - "vscode-jsonrpc": "9.0.0", - "vscode-languageserver-types": "3.18.0" - } - }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", @@ -9957,110 +6854,17 @@ "node": ">= 8" } }, - "node_modules/which-boxed-primitive": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", - "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-bigint": "^1.1.0", - "is-boolean-object": "^1.2.1", - "is-number-object": "^1.1.1", - "is-string": "^1.1.1", - "is-symbol": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-builtin-type": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", - "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "function.prototype.name": "^1.1.6", - "has-tostringtag": "^1.0.2", - "is-async-function": "^2.0.0", - "is-date-object": "^1.1.0", - "is-finalizationregistry": "^1.1.0", - "is-generator-function": "^1.0.10", - "is-regex": "^1.2.1", - "is-weakref": "^1.0.2", - "isarray": "^2.0.5", - "which-boxed-primitive": "^1.1.0", - "which-collection": "^1.0.2", - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-collection": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", - "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-map": "^2.0.3", - "is-set": "^2.0.3", - "is-weakmap": "^2.0.2", - "is-weakset": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.21.tgz", - "integrity": "sha512-zbRA8cVm6io/d5W8uIe2hblzN76/Wm3v/yiythQvr+dpBWeqhPSWIDNj4zOyHi4zKbMK6DN34Xsr9jPHJERAEw==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.9", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", "dev": true, "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, "bin": { "why-is-node-running": "cli.js" }, "engines": { - "node": ">=8" + "node": ">=20.11" } }, "node_modules/word-wrap": { @@ -10385,20 +7189,20 @@ }, "packages/cli": { "name": "@bpmn-script/cli", - "version": "0.0.1", + "version": "0.1.0", "license": "Apache-2.0", "dependencies": { - "@bpmn-script/language": "0.0.1", - "@bpmn-script/transform": "*", - "chalk": "^5.3.0", + "@bpmn-script/language": "0.1.0", + "@bpmn-script/transform": "0.1.0", + "chalk": "^6.0.1", "commander": "^15.0.0", - "langium": "^4.3.1" + "langium": "^4.4.0" }, "bin": { "bpmns": "bin/cli.js" }, "devDependencies": { - "vitest": "^4.1.11" + "vitest": "^5.0.5" }, "engines": { "node": ">=22.0.0", @@ -10406,12 +7210,12 @@ } }, "packages/cli/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-6.0.1.tgz", + "integrity": "sha512-/Ce6KNm3vIbWdMlNna6RVIZ/ICQxnJxCicet5LBKK9ZffBkqzDw0xh9EiKSljdRtiIQ1S1z4YgcscUUGzNCWrA==", "license": "MIT", "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" + "node": ">=22" }, "funding": { "url": "https://github.com/chalk/chalk?sponsor=1" @@ -10419,59 +7223,62 @@ }, "packages/extension": { "name": "vscode-bpmnscript", - "version": "0.0.1", + "version": "0.1.0", "license": "Apache-2.0", "dependencies": { - "@bpmn-script/language": "0.0.1", - "@bpmn-script/transform": "0.0.1", - "vscode-languageclient": "~10.1.1" + "@bpmn-script/language": "0.1.0", + "@bpmn-script/transform": "0.1.0", + "langium": "^4.4.0", + "vscode-languageclient": "~10.1.2" }, "devDependencies": { "@types/vscode": "~1.134.0", "@vscode/vsce": "^4.0.0", - "concurrently": "^10.0.5", + "concurrently": "^10.0.6", "esbuild": "~0.28.2", - "vitest": "^4.1.11" + "vitest": "^5.0.5" }, "engines": { "vscode": "^1.134.0" } }, + "packages/extension/node_modules/@types/vscode": { + "version": "1.134.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.134.0.tgz", + "integrity": "sha512-NDEu0hg4sF7+vvFsADsktqUJ6f80LHSZvVK2Ovo1XiQ0/VHck1O3zst+ZZyVA/uvz6vo6LcuoqU2q48YMqOwWw==", + "dev": true, + "license": "MIT" + }, "packages/language": { "name": "@bpmn-script/language", - "version": "0.0.1", + "version": "0.1.0", "license": "Apache-2.0", "dependencies": { - "langium": "^4.3.1", + "langium": "^4.4.0", "vscode-languageserver-types": "^3.18.3" }, "devDependencies": { - "langium-cli": "^4.3.0", - "vitest": "^4.1.11" + "langium-cli": "^4.4.0", + "vitest": "^5.0.5" }, "engines": { "node": ">=22.0.0", "npm": ">=10.2.3" } }, - "packages/language/node_modules/vscode-languageserver-types": { - "version": "3.18.3", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.3.tgz", - "integrity": "sha512-XIlzJ7Qp/jzSI1ds7/FwPAWrPeTZA7pAtlW4hdJ1J6xXWJL6dR9QYnDhJOdLzdKhUQ5Mm6mvUMw+3DcOQQasPw==", - "license": "MIT" - }, "packages/transform": { "name": "@bpmn-script/transform", - "version": "0.0.1", + "version": "0.1.0", "license": "Apache-2.0", "dependencies": { - "@bpmn-script/language": "*", - "bpmn-auto-layout": "^1.2.0", - "bpmn-moddle": "^10.2.0", + "@bpmn-script/language": "0.1.0", + "bpmn-auto-layout": "^1.3.0", + "bpmn-moddle": "^10.3.1", + "langium": "^4.4.0", "saxen": "^11.1.0" }, "devDependencies": { - "vitest": "^4.1.11" + "vitest": "^5.0.5" }, "engines": { "node": ">=22.0.0", @@ -10479,14 +7286,14 @@ } }, "tests": { - "version": "0.0.1", + "version": "0.1.0", "license": "Apache-2.0", "devDependencies": { - "@bpmn-script/language": "0.0.1", - "@bpmn-script/transform": "0.0.1", - "langium": "^4.3.1", - "testcontainers": "^12.1.0", - "vitest": "^4.1.11", + "@bpmn-script/language": "0.1.0", + "@bpmn-script/transform": "0.1.0", + "langium": "^4.4.0", + "testcontainers": "^12.2.1", + "vitest": "^5.0.5", "vscode-languageserver-types": "^3.18.0" }, "engines": { diff --git a/package.json b/package.json index 0ea6c49..f30ea54 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "bpmn-script-workspace", "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", + "version": "0.1.0", "license": "Apache-2.0", "repository": { "type": "git", @@ -25,14 +25,14 @@ "test": "npm run test --workspaces --if-present" }, "devDependencies": { - "@types/node": "^26.4.0", - "@typescript-eslint/parser": "^8.69.0", - "@vitest/coverage-v8": "^4.1.11", - "eslint": "^9.0.0", + "@eslint/js": "^10.0.1", + "@types/node": "^26.6.5", + "@typescript-eslint/parser": "^8.71.2", + "@vitest/coverage-v8": "^5.0.5", + "eslint": "^10.13.0", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-bpmn-io": "^2.3.1", - "prettier": "^3.9.6", - "shx": "~0.4.0", + "globals": "^17.13.0", + "prettier": "^3.9.10", "typescript": "~6.0.0" }, "workspaces": [ diff --git a/packages/cli/README.md b/packages/cli/README.md index 8721bb7..ef60d57 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,12 +1,11 @@ # bpmns CLI -The `bpmns` binary, for compiling and decompiling BPMNscript files from a terminal. +The `bpmns` command compiles and decompiles BPMNscript files from a terminal. +The subcommand names are easy to get backwards: `build` compiles BPMNscript to BPMN, and `parse` goes the other way. This package holds no transformation logic. -It reads a file, hands it to `@bpmn-script/language` to parse and validate and to `@bpmn-script/transform` to convert, prints whatever went wrong, and writes the result. -What's left is argument handling, file I/O, and turning failures into readable messages and exit codes. - -The two subcommand names are easy to get backwards: `build` compiles DSL to BPMN, `parse` goes the other way. +It reads a file, hands it to `@bpmn-script/language` to parse and validate and to `@bpmn-script/transform` to convert, prints what went wrong, and writes the result. +It is not meant to be imported; use those two packages directly. ## Usage @@ -24,25 +23,35 @@ bpmns parse invoice-approval.bpmn 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. +From a clone of the repo, run it as `npx bpmns` after `npm run build`. + +`build` writes BPMN 2.0 XML with a generated diagram. +When the layout library can't place the graph, `build` writes the document without a diagram, prints `Warning: no diagram could be drawn for this process` with notes naming the cause, and still exits `0`, since Operaton deploys such a file unchanged. + +`parse` writes `.bpmnscript` source and then prints two kinds of warning: what the import dropped or changed, and what the printer couldn't carry into the script. +Some of these bear on what a rebuilt document runs, so read them before running `build` on the output. +`parse` then builds the script it just wrote and lists every error `build` would report, still exiting `0`. +The import and print rules behind those warnings are in [packages/transform/README.md](../transform/README.md#the-import-contract). + +## Exit codes -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. +| Code | Meaning | +| ---- | -------------------------------------------------------------------------------------------------- | +| `0` | Success, with or without warnings | +| `1` | Validation errors or no process on `build`, a BPMN construct the language can't express on `parse` | +| `2` | A file or path problem, or input that can't be read as BPMN XML | +| `3` | An unexpected exception inside a transform, which is a bug in `bpmns` | -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`. +A command that exits `1` writes nothing. +Every message opens with a headline naming what is wrong and where, with its `note:` and `help:` lines indented beneath it. -The package is not meant to be imported programmatically. -Import `@bpmn-script/transform` for the transforms and `@bpmn-script/language` for the Langium services. +## Files and paths + +- Both subcommands read a file from disk, never stdin. +- `build` takes only a `.bpmnscript` file; `parse` takes any file that reads as BPMN XML. +- An existing output file is left alone unless you pass `--force`. +- An `-o` naming the input file itself always exits `2`, even with `--force`. +- An `-o` naming an existing directory writes into it under the input's basename. ## Build and test @@ -65,8 +74,3 @@ npm test | `src/parse.ts` | `parseAction`: read `.bpmn`, `xmlToIr`, `irToDsl`, write `.bpmnscript` | | `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 - -- `@bpmn-script/transform` (workspace) for all four transform functions -- `@bpmn-script/language` (workspace) for the Langium services that parse `.bpmnscript` diff --git a/packages/cli/package.json b/packages/cli/package.json index 6f49527..e796085 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "name": "@bpmn-script/cli", "description": "Command line interface compiling BPMNscript to BPMN 2.0 XML and back", - "version": "0.0.1", + "version": "0.1.0", "license": "Apache-2.0", "repository": { "type": "git", @@ -22,20 +22,21 @@ "bpmns": "./bin/cli.js" }, "scripts": { - "clean": "shx rm -fr *.tsbuildinfo out", + "prepack": "node -e \"fs.cpSync('../../LICENSE', 'LICENSE')\"", + "clean": "node -e \"for (const f of [...fs.globSync('*.tsbuildinfo'), 'out']) fs.rmSync(f, { recursive: true, force: true })\"", "build": "tsc -p tsconfig.json", "build:clean": "npm run clean && npm run build", "typecheck": "tsc -b tsconfig.test.json", "test": "vitest run" }, "dependencies": { - "@bpmn-script/transform": "*", - "@bpmn-script/language": "0.0.1", - "chalk": "^5.3.0", + "@bpmn-script/language": "0.1.0", + "@bpmn-script/transform": "0.1.0", + "chalk": "^6.0.1", "commander": "^15.0.0", - "langium": "^4.3.1" + "langium": "^4.4.0" }, "devDependencies": { - "vitest": "^4.1.11" + "vitest": "^5.0.5" } } diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts index 0cb26be..7dfffe4 100644 --- a/packages/cli/src/build.ts +++ b/packages/cli/src/build.ts @@ -17,9 +17,12 @@ import { CLI_VERSION, type CommandOptions, buildDocument, + diagnosticsOf, fail, + failInternal, formatDiagnostic, guardOutputPath, + indentNotes, resolveInputPath, resolveOutputPath, warn, @@ -38,8 +41,8 @@ export async function buildAction( if (!extensions.includes(path.extname(resolvedInput))) { fail( 2, - `Error: expected a file with one of these extensions: ${extensions.join(', ')}; ` + - 'a .bpmn file is decompiled with `bpmns parse`', + `Error: expected a file with one of these extensions: ${extensions.join(', ')}\n` + + " help: decompile a .bpmn file with 'bpmns parse'", ); } @@ -53,9 +56,7 @@ export async function buildAction( fail(2, `Error: failed to parse ${fileName}: ${(err as Error).message}`); } - const errors = (document.diagnostics ?? []).filter( - (d) => d.severity === DiagnosticSeverity.Error, - ); + const errors = diagnosticsOf(document, DiagnosticSeverity.Error); if (errors.length > 0) { console.error(chalk.red('Validation errors:')); for (const diag of errors) { @@ -68,12 +69,9 @@ export async function buildAction( 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 === DiagnosticSeverity.Warning, - ); - for (const diag of warnings) { + for (const diag of diagnosticsOf(document, DiagnosticSeverity.Warning)) { warn( - `Warning: line ${diag.range.start.line + 1}: ${Diagnostic.getMessageString(diag)}`, + `Warning: line ${diag.range.start.line + 1}: ${indentNotes(Diagnostic.getMessageString(diag))}`, ); } @@ -81,20 +79,20 @@ export async function buildAction( try { ir = astToIr(ast); } catch (err) { - fail(1, `Error: ${(err as Error).message}`); + failInternal(err); } let xml: string; try { xml = await irToXml(ir, { exporterVersion: CLI_VERSION }); } catch (err) { - if (!(err instanceof LayoutError)) { - fail(1, `Error: ${(err as Error).message}`); - } + if (!(err instanceof LayoutError)) failInternal(err); // 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`, + 'Warning: no diagram could be drawn for this process\n' + + ` note: ${err.message}\n` + + ' note: the file deploys, but opens without shapes in a modeler', ); } diff --git a/packages/cli/src/parse.ts b/packages/cli/src/parse.ts index b01a83f..cac71cf 100644 --- a/packages/cli/src/parse.ts +++ b/packages/cli/src/parse.ts @@ -8,18 +8,18 @@ import { EMPTY_INPUT_MESSAGE, readableParseError, xmlInputProblem, - SERVICE_TASK_FORM_ATTRIBUTES, UnsupportedConstructError, - UnsupportedServiceTaskFormError, - UnsupportedElementError, } from '@bpmn-script/transform'; import type { ImportWarning, PrintWarning } from '@bpmn-script/transform'; import { type CommandOptions, buildDocument, + diagnosticsOf, fail, + failInternal, formatDiagnostic, guardOutputPath, + indentNotes, resolveInputPath, resolveOutputPath, warn, @@ -46,7 +46,7 @@ export async function parseAction( if (problem !== undefined) { fail( 2, - `Error: ${problem}; a .bpmnscript file is built with \`bpmns build\``, + `Error: ${problem}\n help: build a .bpmnscript file with 'bpmns build'`, ); } @@ -55,31 +55,13 @@ export async function parseAction( try { ({ ir, warnings } = await xmlToIr(xml)); } catch (err) { - // Subclasses first: they all extend UnsupportedConstructError. - if (err instanceof UnsupportedServiceTaskFormError) { - fail( - 1, - `Error: unsupported ${err.subject.toLowerCase()} form in ${fileName}:\n` + - ` ${err.message}\n` + - ` The attributes are ${SERVICE_TASK_FORM_ATTRIBUTES}.`, - ); - } - if (err instanceof UnsupportedElementError) { - fail( - 1, - `Error: unsupported BPMN element in ${fileName}:\n ${err.message}`, - ); - } if (err instanceof UnsupportedConstructError) { - fail( - 1, - `Error: unsupported BPMN construct in ${fileName}:\n ${err.message}`, - ); + fail(1, `Error: ${fileName}: ${indentNotes(err.message)}`); } fail( 2, `Error: failed to parse ${fileName}: ` + - readableParseError((err as Error).message, xml), + indentNotes(readableParseError((err as Error).message, xml)), ); } @@ -88,26 +70,23 @@ export async function parseAction( try { ({ source: dsl, warnings: printWarnings } = irToDsl(ir)); } catch (err) { - fail(2, `Error: ${(err as Error).message}`); + failInternal(err); } await writeOutput(outPath, dsl); console.log(chalk.green(`Parsed: ${outPath}`)); - // Several messages do not name their step, so the id tells them apart. for (const w of [...warnings, ...printWarnings]) { - warn(`Warning: ${w.elementId}: ${w.message}`); + warn(`Warning: ${indentNotes(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, - ); + const rebuilt = await buildDocument(outPath).catch(failInternal); + const buildErrors = diagnosticsOf(rebuilt, DiagnosticSeverity.Error); if (buildErrors.length > 0) { warn( `Warning: the printed script draws ${buildErrors.length} error(s) ` + - 'when built; hand-repair is needed:', + 'when built and needs hand-repair:', ); for (const diag of buildErrors) { warn(formatDiagnostic(rebuilt, diag)); diff --git a/packages/cli/src/util.ts b/packages/cli/src/util.ts index 3d67449..4f420ef 100644 --- a/packages/cli/src/util.ts +++ b/packages/cli/src/util.ts @@ -5,19 +5,33 @@ 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'; +import { + createBpmnScriptServices, + Diagnostic, + DiagnosticSeverity, +} from '@bpmn-script/language'; export function fail(code: number, message: string): never { console.error(chalk.red(message)); process.exit(code); } +/** Exit 3: an unexpected exception in a transform, as opposed to bad input. */ +export function failInternal(err: unknown): never { + fail(3, `Error: internal error (a bug in bpmns): ${(err as Error).message}`); +} + export function warn(message: string): void { console.error(chalk.yellow(message)); } +/** Indents a message's note and help lines under its headline. */ +export function indentNotes(message: string): string { + return message.replaceAll('\n', '\n '); +} + // One level below the package root in both `out/` and `src/`. -export const CLI_VERSION: string = ( +export const CLI_VERSION = ( JSON.parse( readFileSync(new URL('../package.json', import.meta.url), 'utf-8'), ) as { version: string } @@ -62,7 +76,8 @@ export function guardOutputPath( if (!opts.force && existsSync(outPath)) { fail( 2, - `Error: ${outPath} exists; pass --force to overwrite it or -o for another path`, + `Error: ${outPath} exists\n` + + " help: pass '--force' to overwrite it, or '-o' for another path", ); } } @@ -96,12 +111,22 @@ export async function buildDocument( return document; } +export function diagnosticsOf( + document: LangiumDocument, + severity: DiagnosticSeverity, +): Diagnostic[] { + return (document.diagnostics ?? []).filter((d) => d.severity === severity); +} + export function formatDiagnostic( document: LangiumDocument, diagnostic: Diagnostic, ): string { - return ( - ` line ${diagnostic.range.start.line + 1}: ${Diagnostic.getMessageString(diagnostic)}` + - ` [${document.textDocument.getText(diagnostic.range)}]` - ); + const [headline, ...notes] = + Diagnostic.getMessageString(diagnostic).split('\n'); + return [ + ` line ${diagnostic.range.start.line + 1}: ${headline}` + + ` [${document.textDocument.getText(diagnostic.range)}]`, + ...notes.map((note) => ` ${note}`), + ].join('\n'); } diff --git a/packages/cli/test/build-parse.smoke.test.ts b/packages/cli/test/build-parse.smoke.test.ts index f7ac311..95ca518 100644 --- a/packages/cli/test/build-parse.smoke.test.ts +++ b/packages/cli/test/build-parse.smoke.test.ts @@ -12,7 +12,6 @@ import type { Model } from '@bpmn-script/language'; import { xmlToIr } from '@bpmn-script/transform'; import { - expectMentions, runActionAt, runBuild, runParse, @@ -101,6 +100,12 @@ const TWO_DROPPED_CONDITIONS_BPMN = ` `; +const dropped = (condition: string, step: string): string => + `Warning: condition '\${${condition}}' on the route out of '${step}' was dropped\n` + + ` note: it is the only route out of '${step}', and BPMNscript writes conditions only on the branches of a split\n` + + ' note: when the condition is false, Operaton stops the original process with an error\n' + + ' note: the rebuilt process always continues'; + // A number field whose default the engine's `LongFormType` cannot convert: // imported as written, refused by the validator. const DECIMAL_DEFAULT_BPMN = ` @@ -122,21 +127,12 @@ const DECIMAL_DEFAULT_BPMN = ` `; -type ParseExpectation = { - /** The id of every `Warning: : ...` line, in the order printed. */ - warningIds: string[]; - mentions?: string[]; - 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 [ title: string, input: Input, - expected: ParseExpectation, + /** Every stderr entry, whole. */ + stderr: string[], + script: string, ]; describe('bpmns parse', () => { @@ -144,74 +140,94 @@ describe('bpmns parse', () => { [ 'a BPMN this tool generated decompiles with nothing to report', { file: GOLDEN_GENERATED_BPMN }, - { warningIds: [] }, + [], + `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 +} +`, ], [ 'a region the decompiler cannot phrase is written with a hand-repair marker, and both warnings name the split it starts at', { text: UNSTRUCTURED_FORK_BPMN }, - { - warningIds: ['Fork', 'Fork'], - mentions: ['unstructured region', 'hand-repair'], - script: ['// unstructured region: hand-repair required'], - }, + [ + "Warning: gateway 'Fork' was printed as jumps and its split was lost\n" + + ' note: the branches leaving it 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\n' + + ' note: a condition weighing a branch is written on its jump\n' + + ' note: whether the split opened one branch, every branch whose condition held, or the first to resolve is written nowhere\n' + + ' note: a jump ends the path it sits on, so at most one branch is left running\n' + + ' note: the marker line above the jumps is where hand-repair starts', + "Warning: a route into 'Fork' was left out of the script\n" + + ' note: the route has no form in the script, so this part of the script is unstructured\n' + + " note: the marker comment where it belonged names 'Fork' and is where hand-repair starts", + ], + `process unstructured { + start S + // unstructured region: hand-repair required (split Fork degraded to jumps; was parallel) + if (true) { + goto A + } else { + goto B + } + user A + end E + user B + // unstructured region: hand-repair required (dropped edge into Fork) +} +`, ], [ - 'two steps that lost the same thing are told apart by the id each line leads with', + 'two steps that lost the same thing are told apart by the step each headline names', { text: TWO_DROPPED_CONDITIONS_BPMN }, - { - warningIds: ['CheckStock', 'ReserveGoods'], - sameMessage: true, - script: ['CheckStock', 'ReserveGoods'], - }, + [dropped('inStock', 'CheckStock'), dropped('paid', 'ReserveGoods')], + `process two-conditions { + var inStock: any + var paid: any + start Start + user CheckStock(label: "Check stock") + user ReserveGoods(label: "Reserve goods") + user ShipOrder(label: "Ship order") + end Done +} +`, ], [ '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\. \[.+\]$/, - ], - }, + [ + "Warning: the engine cannot use default '1.5' on form field 'amount' of 'Review' as written\n" + + " note: 'LongFormType.convertValue' parses it with 'Long.valueOf' on every render of the form, and it is not an integer\n" + + ' note: it was imported as written, and the printed script draws an error at the field', + 'Warning: the printed script draws 1 error(s) when built and needs hand-repair:', + " line 5: the default 1.5 of number field 'amount' is not an integer [1.5]\n" + + ' note: the engine converts it with Long.valueOf every time the form renders and throws on anything else', + ], + `process decimal-default-carry { + start S + user Review { + form { + amount: number = 1.5 + } + } + end E +} +`, ], - ])('%s', async (_title, input, expected) => { + ])('%s', async (_title, input, stderr, script) => { const run = await runParse(input); - expect(run.exit).toBeUndefined(); - expect(run.output).toBeDefined(); - - 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); - - 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 = idLines.map((line, i) => - line.slice(`Warning: ${expected.warningIds[i]}: `.length), - ); - expect(new Set(bodies).size).toBe(1); - expect(bodies[0]).not.toBe(''); - } - - const document = await parse(run.output!); + expect(run).toEqual({ exit: undefined, stderr, output: script }); + const document = await parse(script); expect(document.parseResult.parserErrors).toHaveLength(0); }); }); @@ -243,17 +259,14 @@ const TYPE_MISMATCH_SOURCE = `process type-mismatch { } `; -type BuildExpectation = { - exit?: number; - reimportsAs?: string; - stderrLines: number; - mentions?: string[]; -}; - type BuildRow = readonly [ title: string, input: Input, - expected: BuildExpectation, + exit: number | undefined, + /** Every stderr entry, whole. */ + stderr: string[], + /** The process id the written BPMN imports back as; undefined where nothing is written. */ + reimportsAs: string | undefined, ]; describe('bpmns build', () => { @@ -261,44 +274,49 @@ describe('bpmns build', () => { [ 'the invoice-approval example builds to BPMN that imports back under its own id', { file: INVOICE_APPROVAL_SRC }, - { reimportsAs: 'invoice-approval', stderrLines: 0 }, + undefined, + [], + 'invoice-approval', ], [ 'an undeclared variable is only a warning, so the build still writes its output', { text: WARNING_ONLY_SOURCE }, - { - reimportsAs: 'warning-only', - stderrLines: 1, - mentions: ['amount', 'not declared'], - }, + undefined, + [ + "Warning: line 3: variable 'amount' is not declared\n" + + " help: add 'var amount: ' to the process", + ], + 'warning-only', ], [ 'a type mismatch fails the build with exit code 1 and writes nothing', { text: TYPE_MISMATCH_SOURCE }, - { exit: 1, stderrLines: 2, mentions: ['Validation errors:'] }, + 1, + [ + 'Validation errors:', + " line 4: variable 'amount' of type 'string' cannot be used in an ordered comparison with '>' [amount > 1000]", + ], + undefined, ], [ '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'], - }, + 1, + [ + 'Validation errors:', + " line 1: unexpected 'proces' outside any process [proces]", + ], + undefined, ], - ])('%s', async (_title, input, expected) => { + ])('%s', async (_title, input, exit, stderr, reimportsAs) => { const run = await runBuild(input); - expect(run.exit).toBe(expected.exit); - expect(run.stderr).toHaveLength(expected.stderrLines); - expectMentions(run.stderr.join('\n'), expected.mentions ?? []); - - if (expected.reimportsAs === undefined) { + expect([run.exit, run.stderr]).toEqual([exit, stderr]); + if (reimportsAs === undefined) { expect(run.output).toBeUndefined(); return; } - expect(run.output).toBeDefined(); - expect((await xmlToIr(run.output!)).ir.id).toBe(expected.reimportsAs); + expect((await xmlToIr(run.output!)).ir.id).toBe(reimportsAs); expect(run.output!.match(/exporterVersion="[^"]*"/g)).toEqual([ `exporterVersion="${PACKAGE_VERSION}"`, ]); @@ -323,14 +341,14 @@ 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`'; + `Error: not an XML document (starts with '${preview}')\n` + + " help: build a .bpmnscript file with 'bpmns build'"; type GuardCase = { inputPath: string; opts: CommandOptions; exit: number | undefined; - /** The first stderr line, where the row expects one. */ + /** The one stderr entry, where the row expects one. */ line?: string; check?: () => void; }; @@ -370,7 +388,9 @@ const refusesExisting = inputPath, opts: {}, exit: 2, - line: `Error: ${outputPath} exists; pass --force to overwrite it or -o for another path`, + line: + `Error: ${outputPath} exists\n` + + " help: pass '--force' to overwrite it, or '-o' for another path", check: () => expect(fs.readFileSync(outputPath, 'utf-8')).toBe('PRE-EXISTING'), }; @@ -526,8 +546,8 @@ describe('bpmns build / bpmns parse: guards against destructive or unclear failu 'order.txt', VALID_DSL, 2, - 'Error: expected a file with one of these extensions: .bpmnscript; ' + - 'a .bpmn file is decompiled with `bpmns parse`', + 'Error: expected a file with one of these extensions: .bpmnscript\n' + + " help: decompile a .bpmn file with 'bpmns parse'", ), refusedFile( 'build: an empty source is reported plainly, without a pipeline stage name', @@ -577,9 +597,10 @@ describe('bpmns build / bpmns parse: guards against destructive or unclear failu '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)', + `Error: failed to parse ${inputPath}: failed to parse document as \n` + + " note: the root element is ''\n" + + " note: BPMN expects '' in the namespace " + + "'http://www.omg.org/spec/BPMN/20100524/MODEL'", ), refusedFile( 'parse: a wrong bpmn namespace names the namespace it found', @@ -590,9 +611,11 @@ describe('bpmns build / bpmns parse: guards against destructive or unclear failu '', 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)', + `Error: failed to parse ${inputPath}: failed to parse document as \n` + + " note: the root element is ''\n" + + " note: BPMN expects '' in the namespace " + + "'http://www.omg.org/spec/BPMN/20100524/MODEL'\n" + + " note: the file declares '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', @@ -612,7 +635,7 @@ describe('bpmns build / bpmns parse: guards against destructive or unclear failu 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); + expect(run.stderr).toEqual(line === undefined ? [] : [line]); check?.(); } finally { await fsp.rm(dir, { recursive: true, force: true }); diff --git a/packages/cli/test/build.layout-guard.test.ts b/packages/cli/test/build.layout-guard.test.ts index 37815a2..50a9861 100644 --- a/packages/cli/test/build.layout-guard.test.ts +++ b/packages/cli/test/build.layout-guard.test.ts @@ -15,9 +15,9 @@ describe('bpmns build: layoutProcess guard', () => { 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', + 'Warning: no diagram could be drawn for this process\n' + + " note: 'bpmn-auto-layout' failed to lay out the process: Cannot set properties of undefined\n" + + ' note: 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 4581332..1d1444c 100644 --- a/packages/cli/test/decompile-contract.e2e.test.ts +++ b/packages/cli/test/decompile-contract.e2e.test.ts @@ -17,7 +17,7 @@ import { UnsupportedEventFeatureError, } from '@bpmn-script/transform'; -import { runBuild, runParse, expectMentions } from './helpers/actions.js'; +import { runBuild, runParse } from './helpers/actions.js'; const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url)); @@ -87,19 +87,23 @@ describe('decompile contract on the fixtures', () => { ]); 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']); + expect(parsed).toEqual({ + exit: undefined, + stderr: [ + "Warning: lane 'Operations' (Lane_Ops) was not imported\n" + + ' note: every step is placed in a single flat process', + "Warning: 'operaton:properties' on 'ReviewRequest' was not imported\n" + + " note: BPMNscript reads no such setting on a 'bpmn:userTask'\n" + + ' note: the rebuilt document carries none', + ], + output: `process lanes-and-async { + start ReviewStart + user ReviewRequest(label: "Review request", assignee: "demo", asyncBefore: true) + end ReviewDone +} +`, + }); const dsl = parsed.output!; - expectMentions(dsl, [ - 'process lanes-and-async', - 'start ReviewStart', - 'user ReviewRequest', - 'assignee: "demo"', - 'end ReviewDone', - ]); const { document, diagnostics } = await validate(dsl); expect(document.parseResult.parserErrors).toHaveLength(0); @@ -122,11 +126,16 @@ describe('decompile contract on the fixtures', () => { // 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'); - expectMentions(stderr, ['ShipmentFailed', "Catch it with 'on error'"]); - assertNoForbiddenJargon(stderr); + expect(run).toEqual({ + exit: 1, + stderr: [ + `Error: ${ERROR_START_BPMN}: element 'ShipmentFailed' cannot be imported: a process cannot start on an error\n` + + ' note: Operaton ignores the trigger and starts the process as if none were written\n' + + ' note: importing it would write back a document the engine runs differently from what it says\n' + + " help: catch it with 'on error' inside the scope that raises it", + ], + output: undefined, + }); }); }); @@ -139,51 +148,52 @@ describe('decompile contract: the script it hands back goes through the pipeline "a modelling tool's default ids keep their statements and labels", 'StartEvent_1', 'EndEvent_1', - [ - 'start StartEvent_1(label: "Order received")', - 'end EndEvent_1(label: "Order filed")', - ], + `process generated-id-labels { + start StartEvent_1(label: "Order received") + user Approve + 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, + `process generated-id-labels { + user Approve + end EndEvent_generated-id-labels(label: "Order filed") +} +`, + [ + "label 'Order received' on 'StartEvent_generated-id-labels' was not written to the script\n" + + "note: 'StartEvent_generated-id-labels' is the kind of name BPMNscript generates for itself, which a script cannot repeat\n" + + 'note: this start is left out entirely, and its label with it\n' + + 'help: rename it in the diagram to keep the label', + ], + [ + "statement name 'EndEvent_generated-id-labels' is the id generated for the implicit end event of 'generated-id-labels'\n" + + 'help: choose another name', + ], ], ])( 'the DSL produced from a diagram where %s', - async ( - _title, - startId, - endId, - printedLines, - warnedIds, - diagnosticCount, - ) => { + async (_title, startId, endId, printed, labelWarnings, errors) => { const { ir, warnings } = await xmlToIr( labeledTerminalsBpmn(startId, endId), ); const dsl = irToDsl(ir).source; + expect(dsl).toBe(printed); + expect( + warnings.filter((w) => w.category === 'label').map((w) => w.message), + ).toEqual(labelWarnings); 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); - } + diagnostics.map((d) => [d.severity, Diagnostic.getMessageString(d)]), + ).toEqual(errors.map((message) => [1, message])); }, ); }); @@ -215,7 +225,8 @@ process Second { diagnostics.map((d) => Diagnostic.getMessageString(d)).sort(), ).toEqual( [ - 'Only one process is supported per file. Move additional processes into separate files.', + 'only one process is supported per file\n' + + 'help: 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 51ef165..dbe4d3e 100644 --- a/packages/cli/test/helpers/actions.ts +++ b/packages/cli/test/helpers/actions.ts @@ -3,7 +3,7 @@ import * as fsp from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; import chalk from 'chalk'; -import { expect, vi } from 'vitest'; +import { vi } from 'vitest'; import { buildAction } from '../../src/build.js'; import { parseAction } from '../../src/parse.js'; @@ -106,14 +106,3 @@ export const runBuild = (input: Input): Promise => export const runParse = (input: Input): Promise => run(parseAction, '.bpmn', '.bpmnscript', input); - -export function expectMentions( - text: string, - mentions: readonly string[], -): void { - for (const mention of mentions) { - expect(text, `expected to find "${mention}" in: ${text}`).toContain( - mention, - ); - } -} diff --git a/packages/cli/test/internal-failure.test.ts b/packages/cli/test/internal-failure.test.ts new file mode 100644 index 0000000..867ceba --- /dev/null +++ b/packages/cli/test/internal-failure.test.ts @@ -0,0 +1,46 @@ +// Its own file because `vi.mock` applies to the whole file. +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('@bpmn-script/transform', async (importOriginal) => ({ + ...(await importOriginal()), + irToXml: vi.fn(() => { + throw new Error('boom in irToXml'); + }), + irToDsl: vi.fn(() => { + throw new Error('boom in irToDsl'); + }), +})); + +import { runBuild, runParse } from './helpers/actions.js'; + +const BPMN = ` + + + + + + +`; + +describe('an exception inside a transform', () => { + it.each([ + [ + 'build exits 3 and writes nothing', + () => runBuild({ text: 'process p { start S end E }' }), + 'boom in irToXml', + ], + [ + 'parse exits 3 and writes nothing', + () => runParse({ text: BPMN }), + 'boom in irToDsl', + ], + ])('%s', async (_title, go, boom) => { + const run = await go(); + + expect(run.exit).toBe(3); + expect(run.stderr).toEqual([ + `Error: internal error (a bug in bpmns): ${boom}`, + ]); + expect(run.output).toBeUndefined(); + }); +}); diff --git a/packages/extension/README.md b/packages/extension/README.md index aba0fe4..0daade5 100644 --- a/packages/extension/README.md +++ b/packages/extension/README.md @@ -1,70 +1,76 @@ # vscode-bpmnscript The VS Code extension for BPMNscript. -It runs the Langium language server for highlighting, completion, hover, go to definition, references, rename, the outline and inline diagnostics, and it converts the open file between `.bpmnscript` and `.bpmn` from a sidebar panel or the command palette. +It runs the 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-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. +The language intelligence lives in `@bpmn-script/language`, and this package loads it into VS Code. +That wiring is mostly Langium scaffold ([ADR-0003](https://github.com/BPMNscript/bpmnscript/blob/main/docs/decisions/0003-use-langium-as-language-workbench.md)). +What is specific to this project is the conversion layer: 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. -## How it fits together +## Installing -Three parts, two bundles: the client and the conversion layer in `out/extension/main.cjs`, the language server in `out/language/main.cjs`. +```bash +npm run build +npm run package -w packages/extension +code --install-extension packages/extension/vscode-bpmnscript-0.1.0.vsix +``` -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. +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. -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. +To try a change to the extension without packaging it, press F5 in VS Code from the repo root. +A second window opens with the extension loaded. + +## How it fits together + +The extension has three parts in two bundles: the client and the conversion layer in `out/extension/main.cjs`, and the language server in `out/language/main.cjs`. -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), 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. +The client (`src/extension/main.ts`) runs inside VS Code. +It starts the language server when a `.bpmnscript` file opens, registers the conversion commands and the sidebar, and keeps the sidebar in sync with the active editor. -`sidebar-view-provider.ts` implements `WebviewViewProvider` for the "Convert" view in the "BPMNscript" activity-bar container. -The webview (`media/sidebar.{html,css,js}`) posts `{type:'compile'|'decompile'|'open'|'pick', uri}` messages that the provider dispatches to those same commands, so the sidebar and the palette behave identically. -It follows the active editor, showing the current file, one convert button, and a link to the counterpart file when that already exists on disk. -Conversions open their output, so moving between the two files is one click. +The language server (`src/language/main.ts`) runs in a separate process. +It loads `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. +Rename edits every site of a step, a code or a variable. + +The conversion layer is split along the VS Code boundary. +`conversion-core.ts` has no `vscode` import, so vitest tests it without an editor host. +It runs the compile and decompile pipelines and decides what blocks a conversion: errors do, warnings don't. +A decompile returns the warnings from both `xmlToIr` and `irToDsl` in one list. +`conversion.ts` is the VS Code adapter around it, and it behaves as follows: + +- The source is the command's argument, otherwise the active editor, and an unsaved editor's text is used as it stands. +- A file with the wrong extension for the command is refused, and the message names the command that takes it. +- A validation failure opens the Problems panel, where the language server already shows the diagnostics. +- The output goes next to the source with the extension swapped; an existing file is overwritten only after you confirm, and the output opens when written. +- A compile whose diagram can't be drawn still writes the file and shows a warning. +- A BPMN construct the transform refuses shows an error notification with its headline, and nothing is written. +- Warnings show as one notification giving their count. + Its Show Details button opens the BPMNscript output channel, which holds the full messages with their `note:` and `help:` lines. + +`sidebar-view-provider.ts` provides the "Convert" view in the "BPMNscript" activity-bar container. +The webview (`media/sidebar.{html,css,js}`) shows the active file, one convert button, and a link to the counterpart file when it exists on disk. +Its buttons post messages that the provider dispatches to the same commands as the palette, so both behave identically. ## 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. +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 after generation, so a hyphenated name keeps its color past the first `-`, and numbers and operators get a scope. -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. -`build:prepare` copies the injection asset into `syntaxes/` next to the base grammar, and `package.json`'s `contributes.grammars` registers it with `injectTo: ["source.bpmn-script"]`. +A hand-maintained 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 tag selects the embedded scope (`source.js`, `source.python`, `source.ruby`, `source.groovy`); `feel` and any other accepted tag without an installed grammar stay uncolored. +Inside the fence there is highlighting only, with no completion, hover, or diagnostics. +`build:prepare` copies the injection grammar into `syntaxes/`, and `contributes.grammars` in `package.json` registers it with `injectTo: ["source.bpmn-script"]`. -## Build and run +## Build ```bash # From repo root 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` 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. +`esbuild` bundles both entry points into CommonJS under `out/`. +The bundle takes `@bpmn-script/language` and `@bpmn-script/transform` from their compiled `out/` directories, so a source change in either is invisible until you rebuild it, or run `npm run build` from the repo root. ## Source layout @@ -85,4 +91,4 @@ F5 from the repo root stays the way to try a change to the extension itself with | `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 | +| `LICENSE` | Copied from the repo root by `build:prepare`, git-ignored, packed into the vsix | diff --git a/packages/extension/language-configuration.json b/packages/extension/language-configuration.json index c66e306..8e7ed8a 100644 --- a/packages/extension/language-configuration.json +++ b/packages/extension/language-configuration.json @@ -5,8 +5,9 @@ // block comment markers "blockComment": ["/*", "*/"] }, - // the ID terminal in bpmn-script.langium, so a hyphenated name selects whole - "wordPattern": "[_a-zA-Z]\\w*(-\\w+)*", + // the QUOTED_NAME and ID terminals in bpmn-script.langium, so a hyphenated + // name and a name in backticks select whole + "wordPattern": "`(\\\\.|[^`\\\\\\r\\n])+`|[_a-zA-Z]\\w*(-\\w+)*", // symbols used as brackets "brackets": [ ["{", "}"], diff --git a/packages/extension/package.json b/packages/extension/package.json index 3131c43..71b18c1 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -1,7 +1,7 @@ { "name": "vscode-bpmnscript", "description": "VS Code extension for BPMNscript, bundling the Langium language server", - "version": "0.0.1", + "version": "0.1.0", "displayName": "BPMNscript", "publisher": "bpmnscript", "license": "Apache-2.0", @@ -101,9 +101,9 @@ }, "main": "./out/extension/main.cjs", "scripts": { - "clean": "shx rm -fr *.tsbuildinfo out syntaxes", + "clean": "node -e \"for (const f of [...fs.globSync('*.tsbuildinfo'), 'out', 'syntaxes']) fs.rmSync(f, { recursive: true, force: true })\"", "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 && shx cp -f ../../LICENSE ./LICENSE && shx cp -f ../../CHANGELOG.md ./CHANGELOG.md", + "build:prepare": "node -e \"fs.cpSync('../language/syntaxes/bpmn-script.tmLanguage.json', 'syntaxes/bpmn-script.tmLanguage.json'); fs.cpSync('injection/bpmn-script.injection.tmLanguage.json', 'syntaxes/bpmn-script.injection.tmLanguage.json'); fs.cpSync('../../LICENSE', 'LICENSE')\"", "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", @@ -113,16 +113,17 @@ "package": "vsce package" }, "dependencies": { - "@bpmn-script/language": "0.0.1", - "@bpmn-script/transform": "0.0.1", - "vscode-languageclient": "~10.1.1" + "@bpmn-script/language": "0.1.0", + "@bpmn-script/transform": "0.1.0", + "langium": "^4.4.0", + "vscode-languageclient": "~10.1.2" }, "devDependencies": { "@types/vscode": "~1.134.0", "@vscode/vsce": "^4.0.0", - "concurrently": "^10.0.5", + "concurrently": "^10.0.6", "esbuild": "~0.28.2", - "vitest": "^4.1.11" + "vitest": "^5.0.5" }, "vsce": { "dependencies": false diff --git a/packages/extension/src/extension/conversion-core.ts b/packages/extension/src/extension/conversion-core.ts index fdd2c0c..cd8e980 100644 --- a/packages/extension/src/extension/conversion-core.ts +++ b/packages/extension/src/extension/conversion-core.ts @@ -7,6 +7,7 @@ import { } from '@bpmn-script/language'; import type { Model } from '@bpmn-script/language'; import { EmptyFileSystem, URI } from 'langium'; +import type { LangiumDocument } from 'langium'; import * as path from 'node:path'; import { astToIr, @@ -37,7 +38,13 @@ export type CompileResult = | { ok: false; kind: 'error'; message: string }; export type DecompileResult = - | { ok: true; output: string; warnings: (ImportWarning | PrintWarning)[] } + | { + ok: true; + output: string; + warnings: (ImportWarning | PrintWarning)[]; + /** Errors the printed script draws when compiled; it is still returned. */ + buildErrors: ConvDiagnostic[]; + } | { ok: false; kind: 'unsupported'; message: string } | { ok: false; kind: 'error'; message: string }; @@ -45,10 +52,10 @@ const { shared } = createBpmnScriptServices(EmptyFileSystem); let nextDocId = 0; -export async function compileDslToBpmn( +async function withBuiltDocument( source: string, - exporterVersion: string, -): Promise { + use: (doc: LangiumDocument) => T | Promise, +): Promise { const uri = URI.parse(`memory:///conv-${nextDocId++}.bpmnscript`); // Registered so cross-references resolve, removed so the index does not grow. @@ -60,69 +67,72 @@ export async function compileDslToBpmn( try { await shared.workspace.DocumentBuilder.build([doc], { validation: true }); + return await use(doc); + } finally { + shared.workspace.LangiumDocuments.deleteDocument(uri); + } +} - const errors = (doc.diagnostics ?? []).filter( - (d) => d.severity === DiagnosticSeverity.Error, - ); - if (errors.length > 0) { - const diagnostics: ConvDiagnostic[] = errors.map((d) => ({ - line: d.range.start.line, - character: d.range.start.character, - endLine: d.range.end.line, - endCharacter: d.range.end.character, - message: Diagnostic.getMessageString(d), - severity: DiagnosticSeverity.Error, - text: doc.textDocument.getText(d.range), - })); - return { ok: false, kind: 'validation', diagnostics }; - } +function errorDiagnostics(doc: LangiumDocument): ConvDiagnostic[] { + return (doc.diagnostics ?? []) + .filter((d) => d.severity === DiagnosticSeverity.Error) + .map((d) => ({ + line: d.range.start.line, + character: d.range.start.character, + endLine: d.range.end.line, + endCharacter: d.range.end.character, + message: Diagnostic.getMessageString(d), + severity: DiagnosticSeverity.Error, + text: doc.textDocument.getText(d.range), + })); +} - // 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 }; - } +export async function compileDslToBpmn( + source: string, + exporterVersion: string, +): Promise { + try { + return await withBuiltDocument(source, async (doc) => { + const diagnostics = errorDiagnostics(doc); + if (diagnostics.length > 0) { + return { ok: false, kind: 'validation', diagnostics }; + } - let ir; - try { - ir = astToIr(doc.parseResult.value); - } catch (err) { - return { - ok: false, - kind: 'error', - message: `AST to IR conversion failed: ${(err as Error).message}`, - }; - } + // 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 output; - let layoutWarning: string | undefined; - try { - output = await irToXml(ir, { exporterVersion }); - } catch (err) { - if (err instanceof LayoutError) { - output = err.xml; - layoutWarning = err.message; - } else { + let ir; + try { + ir = astToIr(doc.parseResult.value); + } catch (err) { return { ok: false, kind: 'error', - message: `IR to XML conversion failed: ${(err as Error).message}`, + message: `AST to IR conversion failed: ${(err as Error).message}`, }; } - } - return { - ok: true, - output, - ...(layoutWarning !== undefined ? { layoutWarning } : {}), - }; + try { + return { ok: true, output: await irToXml(ir, { exporterVersion }) }; + } catch (err) { + if (err instanceof LayoutError) { + return { ok: true, output: err.xml, layoutWarning: err.message }; + } + return { + ok: false, + kind: 'error', + message: `IR to XML conversion failed: ${(err as Error).message}`, + }; + } + }); } catch (err) { return { ok: false, kind: 'error', message: (err as Error).message, }; - } finally { - shared.workspace.LangiumDocuments.deleteDocument(uri); } } @@ -162,7 +172,20 @@ export async function decompileBpmnToDsl( }; } - return { ok: true, output, warnings: [...warnings, ...printWarnings] }; + // Nothing above runs the validator, so building the script is what catches an error. + let buildErrors: ConvDiagnostic[]; + try { + buildErrors = await withBuiltDocument(output, errorDiagnostics); + } catch (err) { + return { ok: false, kind: 'error', message: (err as Error).message }; + } + + return { + ok: true, + output, + warnings: [...warnings, ...printWarnings], + buildErrors, + }; } // Importing the cli's `resolveOutputPath` would pull chalk and commander into the bundle. diff --git a/packages/extension/src/extension/conversion.ts b/packages/extension/src/extension/conversion.ts index 64d6710..559c296 100644 --- a/packages/extension/src/extension/conversion.ts +++ b/packages/extension/src/extension/conversion.ts @@ -8,6 +8,29 @@ import { type ConvertHandler = (uri?: vscode.Uri) => Promise; +const SHOW_DETAILS = 'Show Details'; + +let output: vscode.OutputChannel | undefined; + +/** + * A notification renders its text on one line, so a message's note and help + * lines go to the output channel, and the notification offers to open it. + */ +function notifyWithDetails( + show: typeof vscode.window.showWarningMessage, + summary: string, + details: readonly string[], +): void { + output ??= vscode.window.createOutputChannel('BPMNscript'); + output.appendLine(summary); + for (const detail of details) { + output.appendLine(` ${detail.replaceAll('\n', '\n ')}`); + } + void Promise.resolve(show(summary, SHOW_DETAILS)).then((choice) => { + if (choice === SHOW_DETAILS) output?.show(true); + }); +} + interface Source { uri: vscode.Uri; name: string; @@ -41,7 +64,7 @@ async function readSource( 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.`, + `BPMNscript: "${name}" is not a ${ext} file. Use "${otherCommand}" for it.`, ); return undefined; } @@ -66,7 +89,7 @@ async function writeOutput( source: Source, ext: '.bpmn' | '.bpmnscript', verb: 'Compiled' | 'Decompiled', - output: string, + content: string, ): Promise { const outputPath = swapExtension(source.uri.fsPath, ext); const outputUri = vscode.Uri.file(outputPath); @@ -75,7 +98,7 @@ async function writeOutput( } await vscode.workspace.fs.writeFile( outputUri, - new TextEncoder().encode(output), + new TextEncoder().encode(content), ); await vscode.window.showTextDocument(outputUri); void vscode.window.showInformationMessage( @@ -136,11 +159,16 @@ export function decompileCommand(): ConvertHandler { const result = await decompileBpmnToDsl(source.text); if (!result.ok) { - await vscode.window.showErrorMessage( + const [headline, ...notes] = result.message.split('\n'); + const summary = result.kind === 'unsupported' - ? `BPMNscript: "${source.name}" contains an unsupported construct: ${result.message}` - : `BPMNscript: Failed to decompile "${source.name}": ${result.message}`, - ); + ? `BPMNscript: "${source.name}" cannot be decompiled: ${headline}` + : `BPMNscript: Failed to decompile "${source.name}": ${headline}`; + if (notes.length === 0) { + await vscode.window.showErrorMessage(summary); + } else { + notifyWithDetails(vscode.window.showErrorMessage, summary, notes); + } return undefined; } @@ -151,12 +179,17 @@ export function decompileCommand(): ConvertHandler { 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}`, + notifyWithDetails( + vscode.window.showWarningMessage, + `BPMNscript: "${source.name}" reported ${result.warnings.length} item(s) during decompile.`, + result.warnings.map((w) => w.message), + ); + } + if (outputUri && result.buildErrors.length > 0) { + notifyWithDetails( + vscode.window.showWarningMessage, + `BPMNscript: The script decompiled from "${source.name}" has ${result.buildErrors.length} compilation error(s) and needs hand-repair.`, + result.buildErrors.map((e) => `line ${e.line + 1}: ${e.message}`), ); } return outputUri; diff --git a/packages/extension/src/extension/main.ts b/packages/extension/src/extension/main.ts index f313bfa..0a845da 100644 --- a/packages/extension/src/extension/main.ts +++ b/packages/extension/src/extension/main.ts @@ -12,13 +12,13 @@ import { } from './conversion.js'; import { SidebarViewProvider } from './sidebar-view-provider.js'; -let client: LanguageClient; +let client: LanguageClient | undefined; export async function activate( context: vscode.ExtensionContext, ): Promise { const extensionVersion = String( - (context.extension.packageJSON as { version?: string }).version ?? '0.0.1', + (context.extension.packageJSON as { version?: string }).version, ); const decompile = decompileCommand(); @@ -47,17 +47,11 @@ export async function activate( vscode.window.onDidChangeActiveTextEditor(() => void provider.refresh()), ); - // refresh() no-ops until the view exists. - void provider.refresh(); - client = await startLanguageClient(context); } export function deactivate(): Promise | undefined { - if (client) { - return client.stop(); - } - return undefined; + return client?.stop(); } async function startLanguageClient( diff --git a/packages/extension/src/extension/sidebar-view-provider.ts b/packages/extension/src/extension/sidebar-view-provider.ts index a2319ec..f23bb11 100644 --- a/packages/extension/src/extension/sidebar-view-provider.ts +++ b/packages/extension/src/extension/sidebar-view-provider.ts @@ -52,32 +52,19 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { }); } - async refresh(): Promise { - if (!this._view) return; - await this._postState(); - } - private async _handleMessage(message: unknown): Promise { const msg = message as { type: string; uri?: string }; switch (msg.type) { case 'ready': - await this._postState(); + await this.refresh(); break; case 'compile': - if (msg.uri) { - await vscode.commands.executeCommand( - 'bpmnscript.compile', - vscode.Uri.parse(msg.uri), - ); - } - break; - case 'decompile': if (msg.uri) { await vscode.commands.executeCommand( - 'bpmnscript.decompile', + `bpmnscript.${msg.type}`, vscode.Uri.parse(msg.uri), ); } @@ -95,7 +82,7 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { } } - private async _postState(): Promise { + async refresh(): Promise { if (!this._view) return; const editor = vscode.window.activeTextEditor; @@ -104,7 +91,7 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { if (editor) { const uri = editor.document.uri; const ext = path.extname(uri.fsPath).toLowerCase(); - const kind: 'dsl' | 'bpmn' | null = + const kind: ActiveFile['kind'] = ext === '.bpmnscript' ? 'dsl' : ext === '.bpmn' ? 'bpmn' : null; activeFile = { @@ -135,8 +122,6 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { private _buildHtml(webview: vscode.Webview): string { const nonce = randomBytes(16).toString('hex'); - const cspSource = webview.cspSource; - const cssUri = webview .asWebviewUri( vscode.Uri.joinPath(this._extensionUri, 'media', 'sidebar.css'), @@ -153,14 +138,11 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { 'media', 'sidebar.html', ).fsPath; - let html = fs.readFileSync(templatePath, 'utf-8'); - - html = html + return fs + .readFileSync(templatePath, 'utf-8') .replaceAll('{{NONCE}}', nonce) - .replaceAll('{{CSP_SOURCE}}', cspSource) + .replaceAll('{{CSP_SOURCE}}', webview.cspSource) .replace('{{CSS_URI}}', cssUri) .replace('{{JS_URI}}', jsUri); - - return html; } } diff --git a/packages/extension/test/bundled-conversion.e2e.test.ts b/packages/extension/test/bundled-conversion.e2e.test.ts index 74b34c6..7739d57 100644 --- a/packages/extension/test/bundled-conversion.e2e.test.ts +++ b/packages/extension/test/bundled-conversion.e2e.test.ts @@ -103,8 +103,9 @@ describe('bundled asset resolution and transform under the shim', () => { const { stdout } = result; - expect(stdout).toContain('PROCESS_ID:invoice-approval'); - expect(stdout).toContain('bpmn:definitions'); + const [idLine, ...xml] = stdout.split('\n'); + expect(idLine).toBe('PROCESS_ID:invoice-approval'); + expect(xml.join('\n')).toContain(' { validate = validationHelper(services.BpmnScript); }); -function expectMentions(text: string, mentions: readonly string[]): void { - for (const mention of mentions) { - expect(text, `expected to find "${mention}" in: ${text}`).toContain( - mention, - ); - } -} - -// The wording belongs to the validator, the rest is this module's mapping. -type DiagnosticPosition = Omit; - -type CompileOutcome = - | { ok: true; reimportsAs: string } - | { ok: false; kind: 'validation'; diagnostics: DiagnosticPosition[] }; - type CompileRow = readonly [ title: string, source: string, - expected: CompileOutcome, + /** The process id the output imports back as, or the whole failure. */ + expected: string | Extract, ]; describe('compileDslToBpmn', () => { @@ -60,7 +49,7 @@ describe('compileDslToBpmn', () => { [ 'the invoice-approval example compiles to BPMN that imports back under its own id', fs.readFileSync(INVOICE_APPROVAL_SRC, 'utf-8'), - { ok: true, reimportsAs: 'invoice-approval' }, + 'invoice-approval', ], [ 'a type mismatch blocks the compile and reports the comparison it rejected', @@ -74,6 +63,8 @@ describe('compileDslToBpmn', () => { character: 6, endLine: 2, endCharacter: 17, + message: + "variable 'name' of type 'string' cannot be used in an ordered comparison with '>'", severity: 1, text: 'name > 1000', }, @@ -83,29 +74,22 @@ describe('compileDslToBpmn', () => { [ 'an undeclared variable is only a warning, so the source still compiles', `process p { if (amount > 1000) { user A } }`, - { ok: true, reimportsAs: 'p' }, + 'p', + ], + [ + 'a comment-only file is refused in plain words, not in astToIr wording', + '// nothing but a comment here\n', + { ok: false, kind: 'error', message: 'the file has no process' }, ], ])('%s', async (_title, source, expected) => { const result = await compileDslToBpmn(source, '0.0.1'); - expect(result.ok).toBe(expected.ok); - if (expected.ok) { - if (!result.ok) return; - expect(result.output).toContain('bpmn:definitions'); - expect((await xmlToIr(result.output)).ir.id).toBe(expected.reimportsAs); + if (typeof expected !== 'string') { + expect(result).toEqual(expected); return; } - - if (result.ok) return; - expect(result.kind).toBe(expected.kind); - expect('output' in result).toBe(false); - if (result.kind !== 'validation') return; - expect( - result.diagnostics.map(({ message: _message, ...position }) => position), - ).toEqual(expected.diagnostics); - expect(result.diagnostics.map((d) => d.message.length > 0)).toEqual( - expected.diagnostics.map(() => true), - ); + if (!result.ok) throw new Error(JSON.stringify(result)); + expect((await xmlToIr(result.output)).ir.id).toBe(expected); }); // `bpmn-auto-layout`'s grid solver throws on this validator-clean shape: a @@ -132,20 +116,9 @@ describe('compileDslToBpmn', () => { if (!result.ok) return; expect(result.output).toContain(' { - const result = await compileDslToBpmn( - '// nothing but a comment here\n', - '0.0.1', + expect(result.layoutWarning).toMatch( + /^'bpmn-auto-layout' failed to lay out the process: \S/, ); - - expect(result).toEqual({ - ok: false, - kind: 'error', - message: 'the file has no process', - }); }); }); @@ -186,33 +159,58 @@ const CONDITIONAL_START_BPMN = ` `; -type DecompileOutcome = - | { - ok: true; - /** Every warning, as [element id, category], in the order reported. */ - warnings: (readonly [string, string])[]; - mentions: string[]; - } - | { ok: false; kind: 'unsupported'; mentions: string[] }; - -type DecompileRow = readonly [ - title: string, - xml: string, - expected: DecompileOutcome, -]; +// A number field whose default the engine's `LongFormType` cannot convert: +// imported as written, refused by the validator. +const DECIMAL_DEFAULT_BPMN = ` + + + + + + + + + + + + + + +`; describe('decompileBpmnToDsl', () => { - test.each([ + test.each<[title: string, xml: string, expected: DecompileResult]>([ [ 'a lane and an unsupported engine attribute are dropped with a warning each, naming the element they came off', LANE_AND_ASYNC_ATTR_BPMN, { ok: true, + output: `process warns { + start S + user AsyncTask(assignee: "alice", asyncBefore: true) + end E +} +`, warnings: [ - ['Lane_Ops', 'lane'], - ['AsyncTask', 'extensionAttribute'], + { + elementId: 'Lane_Ops', + category: 'lane', + message: + "lane 'Ops' (Lane_Ops) was not imported\n" + + 'note: every step is placed in a single flat process', + }, + { + elementId: 'AsyncTask', + category: 'extensionAttribute', + message: + "setting 'operaton:formHandlerClass' on 'AsyncTask' was not imported\n" + + "note: BPMNscript reads no such setting on a 'bpmn:userTask'\n" + + 'note: the rebuilt document carries none', + }, ], - mentions: ['formHandlerClass'], + buildErrors: [], }, ], [ @@ -221,39 +219,80 @@ describe('decompileBpmnToDsl', () => { { ok: false, kind: 'unsupported', - mentions: ['BadService_1', 'no execution discriminator'], + message: + "service task 'BadService_1' uses an unsupported execution form: no execution discriminator\n" + + 'note: the 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 a decision reference on a business rule task\n' + + "note: their attributes are 'operaton:class' (or the deprecated 'camunda:class' alias), 'operaton:expression', 'operaton:delegateExpression', 'operaton:type=\"external\"' with 'operaton:topic', 'operaton:type=\"mail\"' or 'operaton:type=\"shell\"' with their 'operaton:field' children, and 'operaton:decisionRef'", }, ], [ 'a conditional start event comes back as a script the compiler accepts', CONDITIONAL_START_BPMN, - { ok: true, warnings: [], mentions: [] }, + { + ok: true, + output: `process conditional { + var stockLevel: any + start ConditionalStart condition(stockLevel < 5) + end E +} +`, + warnings: [], + buildErrors: [], + }, + ], + [ + 'the printed script is re-validated, so an error it draws is reported while the script is still returned', + DECIMAL_DEFAULT_BPMN, + { + ok: true, + output: `process decimal-default-carry { + start S + user Review { + form { + amount: number = 1.5 + } + } + end E +} +`, + warnings: [ + { + elementId: 'Review', + category: 'carriedAsWritten', + message: + "the engine cannot use default '1.5' on form field 'amount' of 'Review' as written\n" + + "note: 'LongFormType.convertValue' parses it with 'Long.valueOf' on every render of the form, and it is not an integer\n" + + 'note: it was imported as written, and the printed script draws an error at the field', + }, + ], + buildErrors: [ + { + line: 4, + character: 23, + endLine: 4, + endCharacter: 26, + message: + "the default 1.5 of number field 'amount' is not an integer\n" + + 'note: the engine converts it with Long.valueOf every time the form renders and throws on anything else', + severity: 1, + text: '1.5', + }, + ], + }, ], ])('%s', async (_title, xml, expected) => { const result = await decompileBpmnToDsl(xml); - expect(result.ok).toBe(expected.ok); - if (expected.ok) { - if (!result.ok) return; - expect(result.warnings.map((w) => [w.elementId, w.category])).toEqual( - expected.warnings, - ); - expectMentions( - result.warnings.map((w) => w.message).join('\n'), - expected.mentions, - ); - // 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), - ).toEqual([]); - return; - } - - if (result.ok) return; - expect(result.kind).toBe(expected.kind); - expectMentions(result.message, expected.mentions); + expect(result).toEqual(expected); + if (!result.ok) return; + // Parsing alone accepts source the validator refuses, so the script goes + // through the validator, whose errors are exactly the reported ones. + const { diagnostics } = await validate(result.output); + expect( + diagnostics + .filter((d) => d.severity === 1) + .map((d) => Diagnostic.getMessageString(d)), + ).toEqual(result.buildErrors.map((e) => e.message)); }); // The file picker offers every file, and the message goes into a @@ -264,7 +303,7 @@ describe('decompileBpmnToDsl', () => { [ '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)}")`, + `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', diff --git a/packages/extension/test/conversion.test.ts b/packages/extension/test/conversion.test.ts index 01e5250..251449f 100644 --- a/packages/extension/test/conversion.test.ts +++ b/packages/extension/test/conversion.test.ts @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ showInformationMessage: vi.fn(), showTextDocument: vi.fn(), executeCommand: vi.fn(), + appendLine: vi.fn(), })); vi.mock('vscode', () => ({ @@ -25,6 +26,10 @@ vi.mock('vscode', () => ({ showInformationMessage: mocks.showInformationMessage, showTextDocument: mocks.showTextDocument, showOpenDialog: vi.fn(), + createOutputChannel: () => ({ + appendLine: mocks.appendLine, + show: vi.fn(), + }), }, commands: { executeCommand: mocks.executeCommand, @@ -78,13 +83,17 @@ function shownDocuments(): string[] { ); } +function outputLines(): string[] { + return mocks.appendLine.mock.calls.map((call) => String(call[0])); +} + 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.'; +const DROPPED = (id: string): string => + `condition '\${ok}' on the route out of '${id}' was dropped\n` + + 'note: when the condition is false, Operaton stops the original process with an error'; type Expected = { info?: string[]; @@ -93,6 +102,8 @@ type Expected = { returns: string | undefined; shown?: string[]; executed?: string[]; + /** Lines written to the output channel, where note and help lines keep their own line. */ + output?: string[]; }; type Row = readonly [ @@ -122,20 +133,40 @@ describe('conversion commands: what the author is shown', () => { { elementId: 'CheckStock', category: 'droppedCondition', - message: SAME_WORDING, + message: DROPPED('CheckStock'), }, { elementId: 'ReserveGoods', category: 'droppedCondition', - message: SAME_WORDING, + message: DROPPED('ReserveGoods'), + }, + ], + buildErrors: [ + { + line: 2, + character: 0, + endLine: 2, + endCharacter: 4, + message: 'bad default\nnote: it is not an integer', + severity: 1, + text: 'done', }, ], }, { info: ['BPMNscript: Decompiled "example.bpmn" -> "example.bpmnscript"'], warning: [ - 'BPMNscript: "example.bpmn" reported 2 item(s) during decompile: ' + - `CheckStock: ${SAME_WORDING}; ReserveGoods: ${SAME_WORDING}`, + 'BPMNscript: "example.bpmn" reported 2 item(s) during decompile.', + 'BPMNscript: The script decompiled from "example.bpmn" has 1 compilation error(s) and needs hand-repair.', + ], + output: [ + 'BPMNscript: "example.bpmn" reported 2 item(s) during decompile.', + " condition '${ok}' on the route out of 'CheckStock' was dropped\n" + + ' note: when the condition is false, Operaton stops the original process with an error', + " condition '${ok}' on the route out of 'ReserveGoods' was dropped\n" + + ' note: when the condition is false, Operaton stops the original process with an error', + 'BPMNscript: The script decompiled from "example.bpmn" has 1 compilation error(s) and needs hand-repair.', + ' line 3: bad default\n note: it is not an integer', ], returns: '/tmp/example.bpmnscript', shown: ['/tmp/example.bpmnscript'], @@ -148,12 +179,19 @@ describe('conversion commands: what the author is shown', () => { { ok: false, kind: 'unsupported', - message: 'multiple linked processes (pools and message flows).', + message: + "the file contains two executable processes ('a', 'b')\n" + + 'note: BPMNscript imports one process', }, { error: [ - 'BPMNscript: "example.bpmn" contains an unsupported construct: ' + - 'multiple linked processes (pools and message flows).', + 'BPMNscript: "example.bpmn" cannot be decompiled: ' + + "the file contains two executable processes ('a', 'b')", + ], + output: [ + 'BPMNscript: "example.bpmn" cannot be decompiled: ' + + "the file contains two executable processes ('a', 'b')", + ' note: BPMNscript imports one process', ], returns: undefined, }, @@ -203,8 +241,8 @@ describe('conversion commands: what the author is shown', () => { undefined, { warning: [ - 'BPMNscript: "example.bpmnscript" is not a .bpmn file; ' + - 'use "Compile to BPMN" for it.', + 'BPMNscript: "example.bpmnscript" is not a .bpmn file. ' + + 'Use "Compile to BPMN" for it.', ], returns: undefined, }, @@ -272,11 +310,13 @@ describe('conversion commands: what the author is shown', () => { error = [], shown = [], executed = [], + output = [], } = expected; expect(returned?.fsPath).toBe(expected.returns); expect(notifications()).toEqual({ info, warning, error }); expect(shownDocuments()).toEqual(shown); expect(executedCommands()).toEqual(executed); + expect(outputLines()).toEqual(output); const core = command === 'compile' ? compileDslToBpmn : decompileBpmnToDsl; expect(core).toHaveBeenCalledTimes(result === undefined ? 0 : 1); @@ -324,6 +364,9 @@ describe('conversion commands: what reaches the disk', () => { ); expect(returned?.fsPath).toBe(returns); + expect(notifications().warning).toEqual([ + '"/tmp/example.bpmn" already exists. Overwrite?', + ]); expect(vscode.workspace.fs.writeFile).toHaveBeenCalledTimes(writes); }); diff --git a/packages/extension/test/injection-grammar.test.ts b/packages/extension/test/injection-grammar.test.ts index 2e880cb..b9c1724 100644 --- a/packages/extension/test/injection-grammar.test.ts +++ b/packages/extension/test/injection-grammar.test.ts @@ -52,7 +52,9 @@ function matchFence(line: string): { name: string; rule: any } | undefined { describe('fenced-script injection grammar', () => { it('is registered against source.bpmn-script at a path build:prepare copies it to', () => { - expect(injection.injectionSelector).toContain('source.bpmn-script'); + expect(injection.injectionSelector).toBe( + 'L:source.bpmn-script -comment -string', + ); const entry = pkg.contributes.grammars.find( (g: { scopeName: string }) => g.scopeName === injection.scopeName, @@ -60,7 +62,7 @@ describe('fenced-script injection grammar', () => { expect(entry).toBeDefined(); expect(entry.injectTo).toEqual(['source.bpmn-script']); expect(pkg.scripts['build:prepare']).toContain( - `cp -f ./injection/bpmn-script.injection.tmLanguage.json ./${entry.path}`, + `fs.cpSync('injection/bpmn-script.injection.tmLanguage.json', '${entry.path}')`, ); expect(() => readFileSync(path.join(EXTENSION_DIR, entry.path)), @@ -93,7 +95,7 @@ describe('fenced-script injection grammar', () => { expect(match!.rule.contentName, tag).toBe( `meta.embedded.block.${format}`, ); - expect(match!.rule.patterns, tag).toContainEqual({ include: embedded }); + expect(match!.rule.patterns, tag).toEqual([{ include: embedded }]); } }); diff --git a/packages/extension/test/language-configuration.test.ts b/packages/extension/test/language-configuration.test.ts index f9d8c01..38a8e85 100644 --- a/packages/extension/test/language-configuration.test.ts +++ b/packages/extension/test/language-configuration.test.ts @@ -2,7 +2,7 @@ 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'; +import { ID_TERMINAL, QUOTED_NAME_TERMINAL } from '@bpmn-script/language'; const EXTENSION_DIR = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -20,7 +20,9 @@ function readWordPattern(): unknown { } describe('language configuration', () => { - it("the editor's word pattern is the grammar's ID terminal", () => { - expect(readWordPattern()).toBe(ID_TERMINAL.source); + it("the editor's word pattern is the grammar's QUOTED_NAME and ID terminals", () => { + expect(readWordPattern()).toBe( + `${QUOTED_NAME_TERMINAL.source}|${ID_TERMINAL.source}`, + ); }); }); diff --git a/packages/language/README.md b/packages/language/README.md index 8c95cb7..12029a2 100644 --- a/packages/language/README.md +++ b/packages/language/README.md @@ -1,24 +1,14 @@ # @bpmn-script/language -The Langium package: grammar, parser, AST, validator, and language server for BPMNscript. -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 reference for BPMNscript, and the Langium package that implements it: grammar, parser, AST, validator, and language server. -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, hover, navigation, rename, and inline diagnostics. -None of the generated code is checked in; `npm run build` regenerates it. +The grammar in [`src/bpmn-script.langium`](src/bpmn-script.langium) decides what parses. +[Langium](https://langium.org/) generates the parser, the AST types, and a language server (highlighting, completion, hover, navigation, rename, inline diagnostics) from it; `npm run build` regenerates that code, and none of it is checked in. +The validator, [`src/bpmn-script-validator.ts`](src/bpmn-script-validator.ts), adds the rules a grammar can't express, such as "a service task needs exactly one binding" or "a variable must be used as its declared type". -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 rest of this page teaches the language from the first process to the event layer, then lists the diagnostics and the package's own API and layout. -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 - -Steps run top to bottom, so sequence flow is never written out, and control flow uses structured statements instead. -Optional `var` declarations go in the process header, before the body, alongside the header's own settings: `versionTag`, which labels the deployed process definition, `historyTimeToLive`, `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. +## A first process ```bpmnscript process invoice-approval { @@ -35,126 +25,161 @@ process invoice-approval { } ``` -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 | 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 (`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 - -The grammar accepts any key in any element's parens and the validator decides which ones that element has, so an unknown key is a diagnostic naming the element rather than a parse error. -Five engine execution settings are legal on every element that takes settings, `label` and `documentation` are legal on all of them too, and each element kind adds the keys it owns on top. - -| Element | Keys beyond the engine settings | -| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| `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` | -| `script` | `resultVariable` | -| `step` | none | -| `send` | `class`, `expression`, `delegate`, `topic`, `type`, `resultVariable`, `taskPriority` | -| `receive` | `message` | -| `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`, `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`: `jobPriority` already prices each run's job once a step repeats, because `createActivityOnScope` reads it off the step regardless of what repeats around it; the whole-loop job takes the process's own priority instead, see [Repetition](#repetition). - -A user task's `assignee` is the user id: a bare word or a quoted string reaches the engine as written, and `"${who}"` reads a variable. -`candidateUsers` and `candidateGroups` are read the same way. -`dueDate` and `followUpDate` take a period starting with `P`, an ISO date-time such as `"2026-01-01T09:00:00"`, or a `"${...}"` expression, the shapes `DueDateBusinessCalendar.resolveDuedate` parses when the task is created. -`priority` is an integer or an expression yielding one; `TaskDecorator.initializeTaskPriority` parses a constant with `Integer.parseInt` at task creation, so `"high"` deploys and then fails the instance. -A user task names its deployed form with `formKey` or with `formRef`, never both. -`formRef` also needs `binding: latest`, `binding: deployment`, or `version: ` beside it, the same version-pinning a `call` or a `decide` step carries, since the engine refuses to deploy a form reference it cannot resolve a version for. -`version` is a positive whole number, quoted or not, or an expression yielding one, on a `formRef`, a `call`, and a `decide` step alike: the engine parses it as an integer when the step runs (`BaseCallableElement.getVersion`, `TaskEntity.initializeFormRefFromTaskDefinition`), so `1.5` deploys and then fails the instance. - -#### Forms - -A process's own `start` and a `user` task each take one `form` block, `operaton:formData` on the wire, which Tasklist renders as one input per field and which fills one process variable per field when the form is submitted. -A field takes the shape every element takes ([ADR-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. +A file holds one process, which compiles to one `bpmn:process`. +A second `process` in the same file is an error. +Steps run top to bottom, so sequence flow is never written: control flow comes from structured statements such as `if`, `while` and `parallel`. +Declarations (`var` and the event codes) go at the top of the body, before the first statement. +`//` starts a line comment and `/* ... */` a block comment. + +Every element has the same shape ([ADR-0011](../../docs/decisions/0011-structured-grammar-one-bracket-shape.md)): a keyword, a name, then optional parens holding its settings, then optional braces holding what it is built from. +`user ReviewInvoice(assignee: "demo")` has settings and no braces; a form, parameters and listeners go in the braces. + +## Names and labels + +A name is ASCII: a letter or `_`, then letters, digits and `_`, with single hyphens allowed between such runs (`invoice-approval`, never `Review-`). +A name that form cannot spell, such as a keyword or `Task.1`, is written in backticks: `` user `Task.1` ``, `` goto `end` ``. +The backticks are not part of the name, so `` `Review` `` and `Review` are one name, and a backslash takes the next character literally, so `` `a\`b` `` names ``a`b``. +A step's name becomes its BPMN id, so even in backticks it holds only letters, digits, `_`, `-` and `.`, starting with a letter or `_`. +An event code's name is no id and takes any text on one line: ``error `Payment failed`(code: "PF")``, thrown as ``throw error(`Payment failed`)``. +A `field` or `property` key takes any text in backticks as well: ``property `true` = "yes"``. +A variable has no backtick form; see [Variables](#variables). +A step name is unique within the process, a step can't share the process's id, and a name can't match a pattern the compiler synthesizes ids from, its own or the diagram's `_di`, `BPMNDiagram_`, and `BPMNPlane_` ids ([ADR-0010](../../docs/decisions/0010-deterministic-synthesized-ids.md)). + +A step, a call and a sub-process take their diagram name from their id, so `ReviewInvoice` shows as "Review Invoice". +A `label` setting replaces it: `user ReviewInvoice(label: "Review invoice")`. +A `start` and an `end` show a name only when they carry a `label`, and `on`, `throw`, `emit` and `await` take no `label` and show none, except a link event, which shows its link name. + +`documentation` attaches free-form text to the element, spelled like `label`: `user ReviewInvoice(documentation: "Escalate above 1000.")`. +A value inside an element is documented by a key named for the word that introduces it: + +- `conditionDocumentation` on any condition: `if (amount > 1000, conditionDocumentation: "Large amounts need a second look.")`, and the same after `else if`, `while`, `do ... while`, a `parallel` branch's `if`, an `until (...)`, and `start S condition(...)`. +- `timerDocumentation` on a timer: `await timer("PT1H", timerDocumentation: "...")`, `on timeout after "PT8H"(class: "a.B", timerDocumentation: "...")`. +- `countDocumentation` and `eachDocumentation` on a repetition: `user R for 3 each line in lines(countDocumentation: "...", eachDocumentation: "...")`. + +## Variables + +`var : ` declares a process variable so its uses get type-checked. +The types are `string`, `number`, `boolean`, `date`, `json`, and `any`. +Reading a variable nothing declares draws a warning, using it against its declared type an error, and declaring one twice an error. +A form field, a catch binding, a parameter and a repetition's element declare a variable as well, and the repetition variables listed under [Repetition](#repetition) need no declaration. +The importer declares `var x: any` for every variable a printed script reads and nothing else declares ([ADR-0015](../../docs/decisions/0015-the-print-declares-every-variable-it-reads.md)). + +A variable named like a reserved word, such as `process`, is written as the raw expression `"${process}"`. +A name that ends up in an engine expression can't be a JUEL reserved word or contain a hyphen; the reserved words are `empty`, `div`, `mod`, `not`, `and`, `or`, `le`, `lt`, `eq`, `ne`, `ge`, `gt`, and `instanceof`, plus the literals `true`, `false`, and `null`. +The same words are refused as a property read inside a `"${...}"` template, such as `"${order.and}"`. + +## Expressions + +Conditions are parsed into a real expression tree over a subset of JUEL, the engine's expression language: + +- integer, decimal, string, boolean, and `null` literals, +- variable references with `.property` and `[index]` accessors, +- unary `!` and `-`, arithmetic, comparison, `&&` and `||`, the ternary `? :`, and parentheses. + +Anything outside that subset is written as a quoted raw template, `"${...}"` or `"#{...}"`, passed to the engine as written. +A raw template uses the escapes of a string literal, so `"${fn(\"a\")}"` is the expression `${fn("a")}` ([ADR-0032](../../docs/decisions/0032-expressions-raw-templates-and-escapes.md)). +A condition whose static type is not boolean is an error, and so is a template with two `${...}` parts, such as `"${a} ${b}"`, used as an operand. +`on condition(...)` and `await condition(...)` use the same grammar and the same checks as `if`. + +## Statements + +| Statement | What it does | BPMN element | +| ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------ | +| [`process X(...) { }`](#the-process-header) | the process | `bpmn:process` | +| [`var x: number`](#variables) | declares a variable for type checking | none | +| `start X` / `end X` | where the flow begins and ends | start event / end event | +| [`start X (...)`](#starting-on-an-event) | starts the process when an event arrives | start event with a trigger | +| [`end X terminate`](#ending-every-path) | stops every running path | terminate end event | +| [`end X cancel`](#giving-a-block-of-work-up) | gives up the `attempt` block around it | cancel end event | +| [`user X(...)`](#user-tasks) | a step a person performs | user task | +| [`service X(...)`](#service-tasks) | a step the system performs | service task | +| [`script X` + a fenced body](#script-tasks) | an inline script | script task | +| `step X(...)` | a step nothing in the engine automates | task | +| [`send X(...)`](#send-receive-and-decision-tasks) | a step that sends something out | send task | +| [`receive X(...)`](#send-receive-and-decision-tasks) | waits for a message | receive task | +| [`decide X(...)`](#send-receive-and-decision-tasks) | a step a decision table answers | business rule task | +| [`if (c) { } else if (c) { } else { }`](#decisions-and-loops) | runs one branch | exclusive gateway | +| [`else fail`](#stopping-when-no-condition-holds) | stops the run with an error when no condition holds | no default flow | +| [`while (c) { }`](#decisions-and-loops) | checks before each pass | exclusive gateway loop | +| [`do { } while (c)`](#decisions-and-loops) | runs the body once before the first check | exclusive gateway loop | +| [`parallel { { } { } }`](#conditioning-a-parallel-branch) | runs branches at the same time, optionally conditioned | parallel gateways, or inclusive when a branch is conditioned | +| [`goto X`](#what-goto-can-reach) | jumps to a named step in the same container | sequence flow | +| [`subprocess X(...) { }`](#sub-processes) | groups steps into one unit | embedded sub-process | +| [`attempt X(...) { }`](#giving-a-block-of-work-up) | a group of steps that can be given up as one | transaction sub-process | +| [`call X(...) { }`](#call-activities) | starts another process and waits for it | call activity | +| [`on { }`](#the-event-layer) | catches an event anywhere in the surrounding body | event sub-process | +| [`on Host: { }`](#attaching-a-handler-to-one-step) | catches an event while `Host` runs | boundary event | +| [`await (...)`](#awaiting-an-event-inline) | waits here until the event arrives | intermediate catch event | +| [`await { (...) { } (...) { } }`](#waiting-on-several-triggers-at-once) | waits on several triggers and continues down whichever fires first | event-based gateway | +| [`throw (...)` / `emit (...)`](#the-event-layer) | raises an event and ends the path, or raises it and continues | throw event | +| [`emit link(...)` / `await link(...)`](#link-events) | jumps to the catch of the same name | link throw and catch events | + +An element also carries members, which are not statements: + +| Member | What it does | BPMN | +| ----------------------------------------------------------------- | ---------------------------------------------------- | -------------------------- | +| [`for each x in c`](#repetition) | runs a step once per item, or a set number of times | multi-instance marker | +| [`asyncBefore: true`](#engine-settings) | an engine setting, in the parens | `operaton:` attribute | +| [`form { ... }`](#forms) | a form on a `start` or a `user` step | `operaton:formData` | +| [`input x = ...`, `output x = ...`](#input-and-output-parameters) | data into and out of a step | `operaton:inputOutput` | +| [`field x = ...`](#field-injection) | a value injected into the step's Java implementation | `operaton:field` | +| [`property x = ...`](#external-task-extras) | a key and text on a form field or an external task | `operaton:property` | +| [`on create(class: "...")`](#listeners) | code run on a step's lifecycle | execution or task listener | + +Every element that takes settings takes the [engine settings](#engine-settings) and an execution listener, and most take input and output parameters. +The ten activities (`user`, `service`, `script`, `step`, `send`, `receive`, `decide`, `subprocess`, `attempt`, and `call`) also take a repetition clause between the name and the parens. +Inside an element's braces, `on` declares a listener; at statement position it declares an event handler, which always carries a body of statements ([ADR-0020](../../docs/decisions/0020-listeners-reuse-on-inside-the-attribute-block.md)). + +A few rules hold for every body: + +- A process, subprocess, attempt block, handler body, or loop body can't be empty, and an empty `if` or `parallel` branch draws a warning. +- A step after an `end`, a `throw`, a `goto`, an `emit link`, or a block whose every branch ends that way can never run, which is an error, unless a `goto` targets it. + A `start`, an `await link`, or a handler can follow such a statement. +- A `do` body that always ends or redirects the flow is an error, since nothing would reach the loop's check. +- A parameter or listener written in a body rather than in an element's braces is an error. + +## The process header + +The parens after `process X` hold settings the engine stores as written and never evaluates, so none of them takes a `"${...}"` template: -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. +```bpmnscript +process invoice-approval(label: "Invoice approval", versionTag: "1.2", historyTimeToLive: "P90D", candidateStarterGroups: "accounting, management", isStartableInTasklist: false) { + start Received + end Done +} +``` -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 constraint names below, together with `pattern`, are the closed set the parens take. +| Setting | Value | +| ------------------------------------------------- | ----------------------------------------------------------------------------------- | +| `label`, `documentation` | a quoted string | +| `versionTag` | a quoted string of at most 64 characters, labeling the deployed process definition | +| `historyTimeToLive` | days the engine keeps a finished instance's history, quoted as `"PD"` or `""` | +| `candidateStarterUsers`, `candidateStarterGroups` | quoted, comma-separated ids allowed to start the process from Tasklist | +| `isStartableInTasklist` | an unquoted boolean; `false` hides the process from Tasklist's start list | -| 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"` | +An absent `historyTimeToLive` compiles to `"P30D"`, because the engine's default configuration refuses to deploy a process without one. +History cleanup itself runs only once its batch window is configured in the engine. +The header takes no engine settings. -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. +## Tasks -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. +### User tasks -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. -The same line sits in an external task's braces, where the engine keys it by `name` instead of `id`, see [External task extras](#external-task-extras). +A `user` task is a step a person performs in Tasklist. + +- `assignee`, `candidateUsers` and `candidateGroups` take user or group ids: a bare word or a quoted string is passed as written, and `"${who}"` reads a variable. +- `dueDate` and `followUpDate` take a period starting with `P`, an ISO date-time such as `"2026-01-01T09:00:00"`, or a `"${...}"` expression. +- `priority` is an integer or an expression yielding one; `"high"` would deploy and then fail every instance. +- `formKey` or `formRef` names a form deployed elsewhere, never both. + `formRef` also needs `binding: latest`, `binding: deployment`, or `version: ` beside it, since the engine refuses a form reference it cannot resolve a version for, and `binding` or `version` without a `formRef` is an error. + +`version` is a positive whole number, quoted or not, or an expression yielding one, here and on a `call` or a `decide` step; `1.5` would deploy and fail when the step runs. +`binding` and `version` are mutually exclusive in all three places. + +### Forms + +A process's own `start` and a `user` task each take one `form` block, which Tasklist renders as one input per field. +Each field fills the process variable of its name when the form is submitted, so a `var` of the same name has to declare the same type. ```bpmnscript process gym-membership { @@ -184,35 +209,75 @@ process gym-membership { } ``` -#### Service tasks +A field is `id: type "label" = default (settings) { members }`, with the label, default, settings and members optional. +The type is `string`, `number`, `boolean`, `date`, or `enum`. +`number` is the engine's `long`, and an `enum` field stores the id of the chosen value, so its variable is a string. +Field ids are unique within a form. + +The parens take the engine's form constraints, checked in the order written, plus `pattern`, and nothing else: + +| Setting | What the engine checks | Fits | Value | +| ---------------------------------- | ----------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------ | +| `required: true` | a value was submitted, or the variable or default 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 number is at least `n`, or below `n` (`max` is exclusive) | `number` | an integer, or a quoted integer such as `"-5"` | +| `minlength: `, `maxlength: ` | the text has at least, or at most, `n` characters | `string` | an integer, 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 | `date` | `SimpleDateFormat` letters, quoted runs, and non-letters: `"dd/MM/yyyy"` | + +A flag is on when written and off otherwise, so `required: false` is an error telling you to leave it out. +A bound on the wrong type would deploy and then fail every submission, so it's an error. + +A literal default is converted through the field's type every time the form renders. +A `number` default has to be an integer and a `boolean` default `true` or `false` (the engine reads anything else as `false`). +A `date` default is read against the field's `pattern`, or against the engine's default `dd/MM/yyyy` when there is none, so `"2026-01-01"` needs a matching `pattern`. +A bare name or a `"${...}"` default is evaluated by the engine at each render and not checked. + +An `enum` field lists its values in the braces, one `id "Label"` line each in the order the form offers them, the label optional. +Value ids are unique, a literal default has to name one of them, and a value line on a field that is not an `enum` is an error. +An `enum` with no values draws a warning, since the engine deploys it and then rejects every submission. + +A `property = ""` line in a field's braces attaches a key and text that a custom form can read; the engine itself never acts on it. +It fits any field type, its value is a quoted string or a `"${...}"` expression, and it is the only other member a field's braces take. + +A `form` block on any element other than a process's own `start` or a `user` task is an error. + +### Service tasks + +A `service` task takes exactly one of five bindings, which say what runs it ([ADR-0017](../../docs/decisions/0017-service-task-bindings.md)): + +| Binding | Value | What runs | +| ------------ | ------------------------------------ | ----------------------------------------------------------------------------------------- | +| `class` | a Java class name, loaded as written | that `JavaDelegate` | +| `expression` | a `"${...}"` template or a bare name | the expression itself | +| `delegate` | a `"${...}"` template or a bare name | the delegate object the expression resolves to | +| `topic` | a topic name | an external worker fetching that topic, see [External task extras](#external-task-extras) | +| `type` | `"mail"` or `"shell"` | a behaviour built into the engine, see [Mail and shell tasks](#mail-and-shell-tasks) | -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 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. +Quoted text under `delegate` is an error, since it resolves to no delegate, quoted text under `expression` draws a warning, since it evaluates to itself and runs nothing, and a template under `class` is an error, since a class name is never evaluated. +An empty binding value is an error. -#### Send, receive, and decision tasks +`resultVariable` names the variable the return value is stored in, and only an `expression` binding writes it. +Beside `class` or `delegate` it is an error, since the engine refuses to deploy the combination; beside `topic` or `type` it deploys and is never written, so it draws a warning. -A `send` task binds exactly the way a `service` task does: the same five attributes, and the same `resultVariable`. -Operaton runs both through the same execution factory, so the tag changes what the diagram calls the step, not what the engine does with it. +### Send, receive, and decision tasks + +A `send` task binds exactly like a `service` task, with the same five bindings and the same `resultVariable` rules. +The engine runs both the same way, so the keyword changes how the diagram draws the step, not what it does. It carries no message name of its own, unlike `receive` or `await message`. -A `receive` task with `message` set waits for a message of that name, released by a correlation the same way an `await message` statement is. -With no `message` key it still waits, but names nothing to correlate on: only the engine's own signal API can move the token past it. +A `receive` task with `message: "Name"` waits for a message of that name, correlated the same way as an `await message`. +Without `message` it still waits, but only the engine's signal API can move it on. -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`. -`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. +A `decide` task with `decision: ""` evaluates that decision table. +`binding: latest`, `binding: deployment`, or `version: ` pins the deployed table version, as on a `call`. +`mapDecisionResult` picks what lands in `resultVariable`: `singleEntry`, `singleResult`, `collectEntries`, or `resultList`; without a `resultVariable` it is never applied and draws a warning. +Without `decision`, a `decide` task binds like a `service` task, and `binding`, `version` and `mapDecisionResult` are errors there. -#### Mail and shell tasks +### Mail and shell tasks -`type: "mail"` and `type: "shell"` select a behaviour Operaton builds itself, on a `service`, `send`, or `decide` task. -A thrown message cannot bind one, since it opens no member block to carry the fields either behaviour requires. -Each field rides `field = ` the same way a `class` or `delegate` binding's fields do, see [Field injection](#field-injection). +`type: "mail"` and `type: "shell"` select a behaviour the engine builds itself, on a `service`, `send`, or `decide` task. +Each input is a `field = ` line in the braces, like [field injection](#field-injection) into a class. ```bpmnscript process notify-ops { @@ -231,22 +296,80 @@ 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. +`from` sets the sender, falling back to the engine's configured default, `subject` the subject line, and `charset` the 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 deployed and never written, the same as beside `topic:`, and draws the same warning. +`arg1` to `arg5` are positional arguments appended after it in order, unset ones skipped, and `directory` sets the working directory, which defaults to the engine process's own. +`outputVariable` names the variable that receives the command's stdout and `errorCodeVariable` the one that receives its exit code. +Every shell field is a quoted literal, never an expression, and the flags `wait`, `redirectError`, and `cleanEnv` are `"true"` or `"false"`. + +A missing required field is an error, and so is a field name the behaviour does not know. +`resultVariable` beside `type` draws the same warning as beside `topic`. + +### Script tasks + +A `script` task's body is a fenced block after the settings, whose opening tag names the language: + +````bpmnscript +process tally { + script Count(resultVariable: "total") ```groovy + return 40 + 2 + ``` +} +```` + +The tags are `juel`, `javascript`, `js` or `ecmascript`, `groovy`, `python` or `py`, `ruby` or `rb`, and `feel`, in any case. +The tag runs to the first whitespace, so on a one-line fence a space separates it from the code. +Only `juel` runs on every Operaton installation; the others need a JSR-223 script engine on the engine's classpath, which is looked up the first time the script runs, not at deployment. +An unknown tag, an empty body, and an unterminated fence are errors, here and on every other fenced script (a listener, a parameter value). + +## What an element carries + +### Engine settings -#### Input and output parameters +Five engine settings are legal on every element that takes settings: -`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. +- `asyncBefore` and `asyncAfter` put a transaction boundary before or after the step, so the engine continues it in a job. +- `exclusive` says whether that job may run beside other jobs of the same instance. +- `jobPriority` orders the job in the queue. +- `retryCycle` is the ISO cycle a failed job is retried on, such as `"R3/PT10M"`. + +`asyncBefore`, `asyncAfter` and `exclusive` are unquoted booleans, and `retryCycle` is quoted. +A `retryCycle` the engine cannot read is dropped at deployment rather than refused, so it draws a warning. +`jobPriority` is an integer or a `"${...}"` expression. + +The job exists only when `asyncBefore` or `asyncAfter` is set, so `exclusive`, `jobPriority`, or `retryCycle` without one of them configures nothing and draws a warning. +Two cases are exempt. +A timer catch creates its own job, so the three settings apply to it directly. +A process's signal start, an `await signal`, and a signal branch of an `await { }` take `jobPriority` alone without an async flag, since the engine prices their subscription job with it; a signal handler or boundary keeps the warning. + +Each element owns further keys on top, and any other key is an error naming the element: + +| Element | Keys it owns | +| -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `start` | `label`, `documentation`, `initiator` (process start only) | +| `end`, `step`, `subprocess`, `attempt` | `label`, `documentation` | +| `user` | `label`, `documentation`, `assignee`, `formKey`, `formRef`, `binding`, `version`, `candidateGroups`, `candidateUsers`, `dueDate`, `followUpDate`, `priority` | +| `service`, `send` | `label`, `documentation`, `class`, `expression`, `delegate`, `topic`, `type`, `resultVariable`, `taskPriority` | +| `script` | `label`, `documentation`, `resultVariable` | +| `receive` | `label`, `documentation`, `message` | +| `decide` | `label`, `documentation`, `class`, `expression`, `delegate`, `topic`, `type`, `decision`, `binding`, `version`, `mapDecisionResult`, `resultVariable`, `taskPriority` | +| `call` | `label`, `documentation`, `process`, `binding`, `version`, `businessKey`, `mapper`, `mapperDelegate` | +| `throw`, `emit` | `class`, `expression`, `delegate`, `topic`, on a `message` trigger only | +| `on` | the flag `alongside` | +| `await`, a branch of `await { }` | none | + +A repeated step also takes the `run*` settings described under [Repetition](#repetition), and the gateway statements take settings on their head, see [Job settings on a gateway](#job-settings-on-a-gateway). +The same key twice in one element is an error, and so is a value in a shape the element cannot read, such as a quoted `asyncBefore` or an unquoted `retryCycle`. + +The parens hold at most one unkeyed value, the payload of a trigger such as `message("Name")`. +An unkeyed value where nothing reads one is an error (on a task, a call, a subprocess, a listener, the process header, or a `start` or `end` without a trigger, where `start S("PT30M")` is a timer missing its `timer` word), and so is a second unkeyed value. + +### Input and output parameters + +`input = ` and `output = ` in an element's braces map data into and out of the step. +They are legal on every activity: a `user`, `service`, `script`, `step`, `send`, `receive`, or `decide` task, a `subprocess`, an `attempt` block, and a `call`. +An `on` handler takes none, since the engine refuses to deploy a boundary event or an event sub-process with one. ```bpmnscript process order-shipping { @@ -258,12 +381,20 @@ process order-shipping { } ``` -#### Field injection +A value is an expression, a `[ ... ]` list, a `{ key: value }` map, or a fenced script that computes it, and lists and maps nest. +A map key is a name or a quoted string. +Entries keep their written order, and a name may repeat across the two directions but not within one. +An empty or blank string as a value, a list item, or a map value draws a warning, because the engine trims it and reads it as absent. +A repeated step can't map an `output`, since the engine refuses that combination; an `input` is fine. + +### Field injection -`field = ` sets a Java bean property on the class, the delegate expression, or the mail or shell behaviour a step's binding names, `operaton:field` on the wire. -The value is a quoted string, injected literally, or a `"${...}"` expression, evaluated per invocation; a list, a map, and an inline script have no field slot to hold. -A field rides a `class:`, `delegate:`, or `type:` binding and no other, since Operaton builds the field list for whichever of the three a binding names and hands it to none of `expression:`, `topic:`, or `decision:`. -It is legal on a `service`, `send`, or `decide` task bound that way, and, `type:` aside, inside a listener's braces, since a listener binds the same way a service task does. +`field = ` sets a property on the Java object a step's binding names: the `class`, the object a `delegate` resolves to, or the mail or shell behaviour of a `type` ([ADR-0018](../../docs/decisions/0018-forms-and-field-injection.md)). +The value is a quoted string, injected as is, or a `"${...}"` expression, evaluated on each invocation. +A list, a map, a script, an empty value, and a string opening with an escaped `\${` or `#{` are errors. + +A field is legal on a `service`, `send`, or `decide` task bound with `class`, `delegate`, or `type`, and in a listener bound with `class` or `delegate`. +On any other binding, `expression`, `topic`, or `decision`, the engine would never inject it, so it is an error. ```bpmnscript process claim-intake { @@ -278,17 +409,15 @@ process claim-intake { } ``` -#### External task extras +### External task extras + +A `service`, `send`, or `decide` task bound with `topic` hands its work to an external worker, and three members exist for that binding alone: -A `service`, `send`, or `decide` task bound with `topic:` hands its work to an external worker, and three more things ride that binding and no other. -`taskPriority: ` in the parens is `operaton:taskPriority`, the priority a worker's fetch can order by: an integer, a quoted one such as `"-5"`, a bare variable name, or a `"${...}"` expression, since `parsePriority` fails the deployment on any other constant. -A `property = ""` line in the braces is the same member a form field takes, written to the wire as `operaton:property name=` here and `id=` there, since `BpmnParseUtil.parseOperatonExtensionProperties` and `DefaultFormHandler.parseProperties` read the one tag by different attributes. -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`. -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. +- `taskPriority: ` in the parens is the priority a worker's fetch can order by: an integer, a quoted integer such as `"-5"`, a bare variable name, or a `"${...}"` expression. +- `property = ""` in the braces is a key and text the worker receives when it fetches with `includeExtensionProperties`, the same line a form field takes. +- `error when ` in the braces raises a declared [error code](#the-event-layer) when the condition holds, checked when the worker reports a failure and again on completion. + Mappings run in the written order and the first true one wins. + Beside the process variables, the condition can read `externalTask.errorMessage`, `externalTask.errorDetails`, and `externalTask.retries`. ```bpmnscript process card-payment { @@ -304,21 +433,22 @@ process card-payment { } ``` -#### Listeners +Each of the three is an error on any other binding, and on a `throw message` or `emit message` bound with `topic`. +A mapping headed by a word other than `error`, missing its `when`, or naming an undeclared code is an error. + +### Listeners -An element's braces also hold listeners, written with `on ` and the thing to run. -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. +A listener runs code on a step's lifecycle and goes in the element's braces as `on ` plus what to run. -| Listener kind | Events | -| ----------------- | ----------------------------------------------------------------- | -| execution | `start`, `end` | -| task, `user` only | `create`, `assignment`, `complete`, `update`, `delete`, `timeout` | +| Listener | Legal on | Events | +| --------- | ----------------------------- | ----------------------------------------------------------------- | +| execution | every element taking settings | `start`, `end` | +| task | a `user` task | `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`, 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. +A listener runs exactly one thing: a `class`, `expression`, or `delegate` in its parens, valued as on a service task, or a fenced script. +A `class` or `delegate` listener may open braces holding [fields](#field-injection). +`on timeout` carries the timer saying when it fires, an `after`, `at`, or `every` particle and a time, and no other event takes one. +The same event may carry several listeners on one element, and each compiles to its own listener in the order written. ````bpmnscript process claim-review { @@ -332,36 +462,156 @@ process claim-review { } ```` -An `on` inside an element's braces is a listener; an `on` at statement position is a caught BPMN event, told apart by what follows the header: a handler always carries a body of statements, and a listener's own braces, when it carries any, hold field declarations instead. +## Control flow -#### Script tasks +### Decisions and loops -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. +`if (condition) { } else if (condition) { } else { }` runs the first branch whose condition holds. +Without an `else`, the flow carries on after the chain when no condition holds. +`while (condition) { }` checks before each pass, and `do { } while (condition)` runs the body once before the first check. +Each compiles to exclusive gateways, a loop with a back edge. -#### Sub-processes +### Stopping when no condition holds -A `subprocess` is a nested flow container mapping to `bpmn:subProcess`. -Its body is a plain statement block using the same vocabulary as a process body, including another `subprocess`, and BPMN viewers render it expanded with its children inside the parent's bounds. -The same statement takes a second head, `attempt`, for a block of work that can be given up as a unit, see [Giving a block of work up](#giving-a-block-of-work-up). +Ending an `if` chain with `else fail` stops the run with an error when no condition holds, instead of carrying on. -#### Call activities +```bpmnscript +process claim-routing { + var amount: number -A `call` reads like a function call at the process boundary. -`process: ""` names the process to start, an `in` mapping is an argument passed into it, and an `out` mapping is a value read back. -`in *` and `out *` copy every variable rather than naming one. -`version: ` pins a deployed version of the called process, while `binding: latest` and `binding: deployment` select the other two resolution modes, and the two are mutually exclusive. -`businessKey: ""` sets the started instance's business key. + start ClaimReceived + if (amount <= 1000) { + user QuickReview + } else if (amount <= 50000) { + user FullReview + } else fail + end ClaimRouted +} +``` + +The gateway gets no default flow, so the engine fails with `ENGINE-02004 No outgoing sequence flow ... could be selected` when nothing matches. +That is an engine exception, not a BPMN error, so no `on error` handler catches it. +The transaction that reached the gateway rolls back: a start request fails and leaves no instance, a task completion fails and leaves the task open, and behind an `asyncBefore` the job fails and raises an incident once its retries run out. +An empty branch beside `else fail` is how a case carries on rather than failing, so it draws no warning. -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. +A `parallel` block whose every branch is conditioned takes the same closing `else fail`, see the next section. +A loop takes none, and a word other than `fail` after `else` is an error. +`fail` is special only in this position, so `var fail: boolean` is legal. -`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. -A mapper is not one of the bindings field injection rides: Operaton hands a variable mapping no field list, so a `call` carries no `field`. +### Conditioning a parallel branch + +`parallel { { } { } }` runs two or more branches at the same time and continues when all have finished. +A branch may open with `if (condition)`, which runs it only when the condition holds, or with `else`, which runs it only when no sibling's condition held; a branch with neither always runs. + +```bpmnscript +process expense-approval { + var amount: number + + start ExpenseSubmitted + + parallel { + if (amount > 10000) { + user AuditReview(assignee: "auditor") + } + else { + user SkipAudit(assignee: "clerk") + } + } + + end ExpenseProcessed +} +``` + +With no condition anywhere, the statement compiles to a parallel gateway pair. +A condition on any branch turns both the fork and the join into inclusive gateways, and every unconditioned branch still runs beside whichever conditioned ones matched ([ADR-0031](../../docs/decisions/0031-inclusive-and-event-based-gateways.md)). +When every branch is conditioned and none matches, the run continues past the join, through the `else` branch if there is one; a closing `else fail` stops the run instead. + +These combinations are errors: + +- an `else` branch beside a branch with no condition, since that branch always runs and the `else` never would, +- a second `else` branch, or an `else` on a statement with no conditioned branch, +- `else fail` on a statement with no conditioned branch, beside an unconditioned branch, or beside an `else` branch. + +### What `goto` can reach + +`goto X` jumps to the step named `X` in the same container: the process, a `subprocess` or `attempt` body, or a host-less handler's body, at any nesting depth of `if`, loop and `parallel` blocks within it. +It never reaches into another process, into or out of a sub-process, or into or out of a host-less handler, which mirrors BPMN's rule that a sequence flow cannot cross a sub-process boundary. +A `subprocess` is a target from its own container, and so is an `await` that carries a name, unless its trigger is `link`. +An `on` handler has no name and is never a target. + +A hosted handler (`on Host: ...`) is the exception: its body shares the host's container, so a `goto` crosses freely between it and the main flow in both directions. +A host-less handler written inside a hosted handler's body therefore guards the whole surrounding container, not just that body. + +A `goto` into a `parallel` or `await { }` branch from outside it is an error, since the token would leave the join waiting for branches the run never entered. +A target across a boundary fails to resolve, and the message explains the boundary when the name exists elsewhere in the process: + +```text +'X' is inside subprocess 'Y' + note: a goto cannot cross a subprocess boundary +'X' is inside the 'on error(PAYMENT_FAILED)' handler + note: a goto cannot cross an event handler boundary + note: an event handler's steps run only when its event fires +'X' is outside subprocess 'Y' + note: a boundary event attaches to an activity in its own scope +``` + +### Job settings on a gateway + +`if`, `while`, `do ... while`, `parallel`, and `await { }` each create a gateway that has no name of its own. +Its [engine settings](#engine-settings) go in parens on the statement head, after the condition for `if` and the loops. +`if`, `parallel`, and `await { }` also create a join, whose settings share the same parens with a `join` prefix: `joinAsyncBefore`, `joinAsyncAfter`, `joinExclusive`, `joinJobPriority`, `joinRetryCycle`. +An `if` chain is one gateway however many `else if` branches it has, so an `else if` takes no parens 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 bare value in these parens is an error, and so is a `join` key on a loop, which has one gateway; the message names the plain spelling. +`await { }` refuses `asyncAfter`, which the engine refuses on that gateway, so it goes on a branch's trigger instead. +A `join` key on a statement whose branches all end their own path draws a warning, since the join is never reached. + +## Sub-processes, calls, and repetition + +### Sub-processes + +`subprocess X { }` groups steps into an embedded sub-process. +Its body uses the same statements as a process body, including another `subprocess`, and a diagram shows it expanded with its steps inside. +`attempt X { }` is the same container for work that can be given up, see [Giving a block of work up](#giving-a-block-of-work-up). + +### Call activities + +`call X(process: "") { }` starts another process and waits for it to finish ([ADR-0019](../../docs/decisions/0019-a-call-activitys-variable-mapping.md)). +In the braces, `in target = source` passes a value into it and `out target = source` reads one back, the left side naming the variable in the receiving process; `in x` and `out x` copy a variable under its own name, and `in *` and `out *` copy every variable. +`in local x = y` reads from or writes to variables of the call step itself rather than the whole process, which is mostly useful for imported diagrams. +A mapping repeated within one direction is an error. + +`version: `, `binding: latest`, or `binding: deployment` picks the deployed version of the called process, never more than one. +`businessKey: ""` sets the started instance's business key. + +`mapper: ""` or `mapperDelegate: "${bean}"` names an implementation of Operaton's `DelegateVariableMapping`, valued like `class` and `delegate` on a service task. +It runs after the declared `in` and `out` mappings and adds to them. +Writing both is an error, and a `call` carries no `field`, since the engine injects none into a mapper. ```bpmnscript process order-handling { @@ -375,33 +625,27 @@ process order-handling { } ``` -#### Repetition +A missing `process` and an unknown `binding` value are errors. -A `for` clause after a statement's id, ahead of its parens, says how often the step runs. -It is legal on `user`, `service`, `script`, `step`, `send`, `receive`, `decide`, `subprocess`, `attempt`, and `call`, and compiles to `bpmn:multiInstanceLoopCharacteristics`. -`for`, `each`, `sequentially`, and `until` are reserved, so none of the four doubles as a plain name elsewhere in a process. +### Repetition -| Clause | Runs | -| -------------------------- | ---------------------------------------------------------------------- | -| `for each line in lines` | once per element of `lines`, each run seeing its own element as `line` | -| `for each in lines` | once per element of `lines`, binding no element | -| `for 3` | three times | -| `for 3 each line in lines` | three times, run `n` still seeing element `n` of `lines` | +A `for` clause between an activity's name and its parens runs it several times, as a multi-instance activity ([ADR-0022](../../docs/decisions/0022-repetition-the-for-clause-and-its-per-run-job-settings.md)). -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`). +| Clause | Runs | +| -------------------------- | ------------------------------------------------------------------ | +| `for each line in lines` | once per element of `lines`, each run seeing its element as `line` | +| `for each in lines` | once per element of `lines`, binding no element | +| `for 3` | three times | +| `for 3 each line in lines` | three times, run `n` seeing element `n` of `lines` | -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. -An element name with no collection to take it from is a deployment error in Operaton, which is why the grammar offers one only inside `each ... in ...`. +A literal count is a non-negative whole number, quoted or not, and a bare name or a `"${...}"` expression is left to the engine. +A collection written as a plain name is the process variable of that name; anything else is a quoted expression, so `for each line in "${order.lines}"` reads a property. -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, 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. +The runs happen at once unless `sequentially` orders them one after another. +`until (condition)` cancels the runs still outstanding once the condition holds. +The condition can read `nrOfInstances`, `nrOfActiveInstances`, and `nrOfCompletedInstances`, kept for the repetition as a whole, and `loopCounter`, the index of the current run, which the run's body and its `input` parameters can read too. +A sequential repetition checks the condition after each run; a parallel one checks it whenever any run ends, with `loopCounter` naming that run. +These four and the element name need no `var`. ```bpmnscript process invoice-batch { @@ -416,11 +660,11 @@ process invoice-batch { } ``` -`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`: `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). +On a repeated step, `asyncBefore`, `asyncAfter`, `exclusive`, and `retryCycle` configure one job around the whole repetition. +`runAsyncBefore`, `runAsyncAfter`, `runExclusive`, and `runRetryCycle`, in the same parens, configure a job for each run instead. +There is no `runJobPriority`: `jobPriority` already prices each run's job, and the whole-repetition job takes the process's priority. +`runExclusive` and `runRetryCycle` need `runAsyncBefore` or `runAsyncAfter` beside them, and so does `jobPriority` on a repeated step, or they configure nothing and draw a warning. +A `run*` key on a step without a `for` clause is an error. ```bpmnscript process order-notifications { @@ -430,148 +674,126 @@ process order-notifications { } ``` -A repeated step cannot map an `output` parameter, because Operaton refuses to deploy that combination outright. -An `input` parameter is fine. - -### Conditioning a parallel branch - -A `parallel` branch may open with `if (condition)`, with `else`, or with nothing at all. -A branch with no head always runs, exactly like an ordinary `parallel` branch. -A branch headed by `if (condition)` runs only when that condition holds. -An `else` branch runs only when no condition on a sibling branch held. +## Events -The head decides the element for the whole statement, not just for its own branch. -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 event layer -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. +The event layer catches and raises BPMN events. +An `on { }` handler is a catch block: it sits at the end of the body it guards, after the last statement, the way `catch` follows `try`, and it guards the process, a `subprocess` or `attempt` body, or another handler's body. +A handler anywhere else, or before a statement of its body, is an error. ```bpmnscript -process expense-approval { - var amount: number +process payment { + error PAYMENT_FAILED(message: "The payment failed") + var reason: string - start ExpenseSubmitted + start Ordered + service Pay(class: "com.example.Pay") + end Paid - parallel { - if (amount > 10000) { - user AuditReview(assignee: "auditor") - } - else { - user SkipAudit(assignee: "clerk") - } + on error(PAYMENT_FAILED, message: reason) { + user HandleFailure(assignee: "demo") + end Failed } - - end ExpenseProcessed } ``` -A headless branch beside an `else` is rejected: it runs every time, so the fork's flow set never comes out empty and the fallback behind the `else` never fires. -A second `else` branch on one statement is rejected, and so is an `else` on a statement where no branch carries a condition, since with nothing conditioned every branch already runs and there is nothing left to fall back from. +A catch binding pairs a field of the event (`code`, and for `error` also `message`) with the variable that receives it, like the `e` in `catch (Exception e)`. +Leaving the code out, as in `on error { }`, catches any event of that kind. +`alongside` makes a handler non-interrupting, so the guarded body keeps running beside it. + +`throw ` raises an event and ends the current path, and `emit ` raises it and continues. +`emit link` is the one emit that ends its path, see [Link events](#link-events). + +An error or escalation code is declared at the top of the process body, beside the `var` declarations, as `error CODE(message: "text")` or `escalation CODE`, and every throw, catch, and [error mapping](#external-task-extras) names it ([ADR-0023](../../docs/decisions/0023-event-roots-from-usage-and-declared-codes.md)). +`message` is the text a thrown error carries; an escalation has none. +A code that isn't name-shaped goes on the declaration as `code: "..."`, so every use site is a plain name. + +| Trigger | Payload | `alongside`? | `throw` (ends the path) | `emit` (continues) | +| -------------- | ------------------------------------------------------------- | ------------------------------ | ------------------------ | ------------------------------------ | +| `error` | a declared code, optional `code: c` and `message: m` bindings | no, an error always interrupts | `throw error(CODE)` | none | +| `escalation` | a declared code, optional `code: c` binding | yes | `throw escalation(CODE)` | `emit escalation(CODE)` | +| `message` | a name | yes | `throw message("Name")` | `emit message("Name")` | +| `signal` | a name | yes | `throw signal("Name")` | `emit signal("Name")` | +| `timer` | a duration, or an `at` or `every` key and a time | yes | none | none | +| `condition` | a condition | yes | none | none | +| `compensation` | none | no, its scope already finished | `throw compensation` | `emit compensation` | +| `cancel` | none | no, the block stops either way | none, `end X cancel` | none | +| `link` | a name | no handler, only `await link` | none | `emit link("Name")`, ending the path | + +What each trigger means: + +- `error` and `escalation` are plain catch blocks. + An escalation only travels up within its own guarded scope. +- `message` is received by correlation on its name, by a handler, a message start, an `await`, or a `receive` task. + `throw message` and `emit message` send one only when they carry a `class`, `expression`, `delegate`, or `topic` binding; without one the event only shows in the diagram, which draws a warning. + More than one binding is an error, and so is a binding on any other trigger. +- `signal` is a broadcast: `emit signal("Name")` notifies every catch anywhere waiting on that name. +- `timer` reads its form from the parens: `"PT1H"` is a duration, `at: "2026-08-01T09:00:00"` a fixed moment, `every: "R/PT10M"` a cycle, see [Timers](#timers). + The clock starts when the guarded scope starts, and an interrupting `every` fires once, since firing ends the scope, so it draws a warning; `alongside` keeps it firing. +- `condition` is checked when the guarded scope starts, and again whenever a variable it reads changes. +- `compensation` is a subprocess's undo block, see [Undoing finished work](#undoing-finished-work). +- `cancel` gives up an `attempt` block, see [Giving a block of work up](#giving-a-block-of-work-up). +- `link` is a jump drawn as two events, see [Link events](#link-events). + +Two handlers that register the same catch on one scope are an error; a handler inside a block and one attached to that block share the scope. +So is an escalation catch-all beside a coded escalation catch on the same scope. + +### Timers + +A timer value has to fit its particle: + +- a duration (`after`, or a bare value) is an ISO 8601 duration such as `"PT8H"`, alone or beside a start or end date-time, +- `at` is an ISO date-time or a period, +- `every` is a repeat such as `"R3/PT10M"`, a six-field cron expression, or a nickname such as `"@daily"`. + +A value that doesn't fit is an error on a timer start, which the engine parses at deployment, and a warning everywhere else, since the engine reads it only once the scope is entered. +An unknown particle is an error. +A timer job setting written both on an `on timer` head and on its body's `start` is an error. -### The event layer +### Attaching a handler to one step -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. - -A binding beside the code in the parens is the caught value, the `e` in `catch (Exception e)`. -It pairs a binding field (`code`, and for `error` also `message`) with the process variable that receives it: `on error(PAYMENT_FAILED, code: c, message: m) { ... }`. -Omitting the code, as in `on error { }`, catches any event of that kind. - -`alongside` marks a handler non-interrupting, so the guarded scope's main flow keeps running beside it. - -Two verbs raise an event. -`throw ` ends the current path right there, exactly like `throw` in a programming language, and `emit ` fires the event and keeps going. -`emit link` is the one emit that ends its path instead, because the token continues at the catch of the same name, see [Link events](#link-events). - -Every error and escalation code is a declaration in the process header, `error CODE(message: "text")` or `escalation CODE`, that every throw, emit, and catch site names, as does an external task's `error when ` mapping (see [External task extras](#external-task-extras)). -The declarations sit with the `var` declarations at the top of the body. -`message:` is the text a thrown error of that code carries at runtime; BPMN gives an escalation none. -A code that is not identifier-shaped goes on its declaration as `code: "..."`, so the use site is a bare name in every case. - -There are nine trigger kinds, and every one but `link` opens an `on` handler: - -| Trigger | Payload | `alongside`? | `throw` (ends the path) | `emit` (continues) | -| -------------- | ------------------------------------------------------------- | ---------------------------------- | ----------------------------------- | ---------------------------------------- | -| `error` | a declared code, optional `code: c` and `message: m` bindings | no, an error always interrupts | `throw error(CODE)` | none, an error ends its path | -| `escalation` | a declared code, optional `code: c` binding | yes | `throw escalation(CODE)` | `emit escalation(CODE)` | -| `message` | a name | yes | `throw message("Name")` | `emit message("Name")` | -| `signal` | a name | yes | `throw signal("Name")` | `emit signal("Name")` | -| `timer` | a duration, or an `at` or `every` key and a time | yes | none, it fires off the clock | none | -| `condition` | a condition expression | yes | none, it fires off data | none | -| `compensation` | none | no, its scope has already finished | `throw compensation` | `emit compensation` | -| `cancel` | none | no, the block stops either way | none, an `end` carries it | none | -| `link` | a name | no handler, only `await link` | none, a link continues at its catch | `emit link("Name")`, which ends the path | - -`timer` and `condition` are handler-only: nothing in the process raises them. -`cancel` is carried on an `end` statement rather than raised by a verb, and it is the one kind with no host-less handler, since it is caught on the block it gives up. -`link` has no handler at all: it is caught by an `await link` of the same name and by nothing else. -The rest, message included, are both caught and raised, under the one rule above. - -Reading each kind: - -- `error` and `escalation` are the plain catch blocks. - An error always interrupts the scope it guards, since BPMN has no non-interrupting error. - An escalation may interrupt or, with `alongside`, run beside the main flow, and only ever travels up within its own guarded scope. -- `message` is both received and sent. - The handler and a message start event receive one by correlation, the engine's own mechanism, so either needs only the name. - `emit message("Name")` and `throw message("Name")` send one. - A `class`, `expression`, `delegate`, or `topic` setting is what makes the engine send it, four of the five bindings a `service` task takes. - `type` is not among them, since a thrown message opens no member block for the fields a mail or shell task needs. - Without one the event delivers nothing. -- `signal` is a broadcast channel. - `emit signal("Name")` notifies every listener anywhere waiting on that name and continues; `throw signal("Name")` broadcasts the same way and ends this path. - Its reach is wider than an escalation's. -- `timer` picks its BPMN form from what the parens carry: a bare `"PT1H"` is a duration, `at: "2026-08-01T09:00:00"` a fixed moment, `every: "R/PT10M"` a repeating cycle. - The clock starts when the guarded scope starts. - An interrupting `every` fires once, because the first firing ends the scope it's timing; `alongside` lets it keep firing. -- `condition` reads like `if`: the expression is checked when the guarded scope starts, and again whenever a variable it reads changes. -- `compensation` is a subprocess's undo block, the steps that reverse whatever the subprocess already finished. - It runs when compensation reaches the subprocess, and only for an instance that actually completed, since one that never finished has nothing to undo. - Two routes send it there: a `throw compensation` or `emit compensation` in an enclosing scope, and a cancel end giving up an `attempt` block the subprocess sits in. - It carries no code, name, condition, or timer, there is at most one per subprocess, and it sits directly inside the `subprocess` or `attempt` body it undoes. - A process has no completed enclosing scope to reverse, so `on compensation` at process level is rejected. - `throw compensation` undoes the nearest enclosing scope's completed work newest first and ends the path; `emit compensation` runs the same undo, waits for it, and continues. -- `cancel` gives up a whole block of work written with `attempt`. - `end cancel` inside the block gives it up, `on : cancel` beside the block catches it, and the undo blocks of the finished steps run in between, see [Giving a block of work up](#giving-a-block-of-work-up). -- `link` is a jump drawn as two events, the throwing `emit link` and the catching `await link`, paired by name, see [Link events](#link-events). +`on : (...) { }` attaches a handler to one step as a boundary event, catching only while that step runs ([ADR-0027](../../docs/decisions/0027-boundary-events-attached-to-an-activity.md)). +`on ReviewInvoice: timer("PT2H") { ... }` reads "if reviewing the invoice takes longer than two hours, do this instead". +The colon tells a host from a trigger. -### Attaching a handler to one step +```bpmnscript +process invoice-review { + start Received + user ReviewInvoice(assignee: "demo") + end Done -Every trigger but `compensation` and `link` can name a single host step instead of guarding a whole body, and `cancel` is the one that has to. -`on : (...) { }` attaches the handler to `` as a `bpmn:boundaryEvent`, catching only while that one step runs, so `on ReviewInvoice: timer("PT2H") { ... }` reads "if reviewing the invoice takes longer than two hours, do this instead" rather than "at any point in the process". + on ReviewInvoice: timer("PT2H") { + user Escalate(assignee: "manager") + goto Done + } +} +``` -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. +The host is an activity: a `user`, `service`, `script`, `step`, `send`, `receive`, or `decide` task, a `subprocess`, an `attempt` block, or a `call`. +Anything else, an `await` included, is an error. +An escalation attaches only to a `subprocess`, an `attempt`, a `call`, or a `user` task, and a cancel only to an `attempt`. +Every trigger but `compensation` and `link` can attach, and `cancel` has to. +`alongside` lets the host run on beside the handler; without it the host is cancelled when the trigger fires. -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, 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. +A hosted body ends where its last statement ends and never falls through to what follows the host, since a non-interrupting boundary would otherwise run the rest of the process twice. +Rejoin the main flow with a `goto`, as above. +A hosted body can't open with a `start`. -A hosted handler's body is self-contained the way any `{ }` block is. -It ends where its last statement ends and never falls back into whatever follows the host, because falling through by default would duplicate the token for a non-interrupting boundary and run the rest of the process twice. -Rejoining is spelled out with a `goto`, which works in both directions, since the body's steps share the host's own container rather than living in one of their own. -An explicit `start` cannot open a hosted body, unlike a host-less one: the body is entered from the boundary event, not from a container's entry point. +### Undoing finished work -To make a single step undoable rather than a whole subprocess, wrap that step in its own `subprocess` with an `on compensation` block. -BPMN can also attach a compensation handler directly to one activity, through a boundary event plus a separate, normally dormant activity that only runs under compensation. -`compensation` is the one trigger kind with no attached form here: wrapping in a `subprocess` already reaches the same granularity with a construct the language has, and BPMN's boundary mechanism needs that second dormant activity the language does not otherwise expose. +`on compensation { }` inside a `subprocess` or `attempt` body is that sub-process's undo block, the steps that reverse what it finished ([ADR-0028](../../docs/decisions/0028-compensation-as-subprocess-undo-block.md)). +It runs only for an instance that completed, in two cases: a `throw compensation` or `emit compensation` in an enclosing scope, or a cancel end giving up an `attempt` block the subprocess sits in. +`throw compensation` undoes the enclosing scope's completed work newest first and ends the path; `emit compensation` waits for the undo and continues. -An undo block sees the subprocess's variables as they stood when it completed, so anything changed by whatever ran afterward is invisible to the steps reversing it. -Operaton does not carry compensation across the separate process instance a `call` starts, so a called process has to compensate its own work. +A sub-process has at most one undo block, it carries no payload, and `on compensation` at process level is an error, since a process has no completed scope to reverse. +To make one step undoable, wrap it in its own `subprocess` with an undo block; compensation has no hosted form. +An undo block sees the subprocess's variables as they were when it completed. +Operaton doesn't carry compensation into the process a `call` starts, so a called process compensates its own work. ### Awaiting an event inline -`await ()` is a statement on the main flow rather than a catch block, and it blocks instead of firing. -Written where any other step would go, as in `await message("Invoice Received")`, the token stops there until the trigger fires and then falls through to whatever follows, exactly like a `bpmn:intermediateCatchEvent`. -An `on` handler races its trigger against the rest of the guarded body and fires only if the trigger wins; a plain `await` sets nothing against its trigger and blocks until it fires. -[`await` with several branches](#waiting-on-several-triggers-at-once) races its own branches against each other instead. - -Five trigger kinds can be awaited: `message`, `timer`, `signal`, and `condition` use the same payload surfaces `on` and a boundary event already use, and `link` has no other surface and gets [its own section](#link-events). +`await ()` is a step on the main flow: the path stops until the event arrives, then continues with the next statement. ```bpmnscript process invoice-intake { @@ -584,18 +806,15 @@ process invoice-intake { } ``` -`error`, `escalation`, `compensation`, and `cancel` have no awaited form: an error or escalation is raised and caught by a racing handler rather than blocked on, compensation is invoked by the engine's compensation machinery, and a cancel is written on the `end` that gives an `attempt` block up. -Writing one of the first three after `await` is rejected with a diagnostic naming the five legal words, and `await cancel` draws its own, pointing at the `end cancel` that gives a block up and the `on : cancel` that catches it. - -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-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. +The awaitable triggers are `message`, `timer`, `signal`, `condition`, and [`link`](#link-events), with the same payloads as on `on`. +`await error`, `await escalation`, and `await compensation` are errors naming the legal words, and `await cancel` points at `end X cancel` and `on X: cancel` instead. +An `await` takes no host, binding, `alongside`, or body. +An optional name between the trigger and the payload, `await message Paid("Paid")`, makes it a `goto` target ([ADR-0026](../../docs/decisions/0026-intermediate-catch-events.md)). ### Link events -A link is a jump drawn as two events with no sequence flow between them, BPMN's off-page connector. -`emit link` is the throwing end, `await link` the catching end, and the quoted link name is what pairs them. -Both ends carry the link name as their diagram label. +A link is a jump drawn as two events with no flow between them, a diagram's off-page connector. +`emit link` throws, `await link` catches, and the quoted link name, also their diagram label, pairs them. ```bpmnscript process invoice-rework { @@ -614,22 +833,20 @@ 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, 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. +`emit link` ends its path like a `goto`, so nothing written after it runs. +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. +A name has one `await link` in the whole file and any number of `emit link`. +Both ends sit in one container, as with `goto`, and an `emit link` can't reach into a `parallel` or `await { }` branch from outside it. -`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-0030](../../docs/decisions/0030-link-events-for-import-round-trip-symmetry.md) makes that argument. +These are errors: an `emit link` with no catch, a `throw link`, a `goto` onto a link catch, `link` heading a branch of `await { }`, and an engine setting or listener on `emit link`, where the engine creates nothing to run it. +An `await link` that nothing emits is a warning, since an imported diagram may carry one. +Write `goto` to jump in your own source; `link` exists so that a diagram carrying one imports and compiles back to the same two events ([ADR-0030](../../docs/decisions/0030-link-events-for-import-round-trip-symmetry.md)). ### Waiting on several triggers at once -`await { ... }` with two or more branches compiles to a `bpmn:eventBasedGateway` with one `bpmn:intermediateCatchEvent` per branch, waiting on every trigger at once and continuing down whichever fires first. -Two branches are the minimum; write a single `await ()` instead when there is only one. -Each branch opens the same way a plain `await` does: a trigger word, its payload and settings, then a body the plain form has no place for; `link` is the one awaitable kind a branch cannot head, and a branch carries no name. +`await { }` with two or more branches waits on every trigger at once and continues down whichever fires first, as an event-based gateway. +Each branch is a trigger, its payload and settings, and a body; it carries no name, and `link` can't head one. +For a single trigger, write a plain `await`. ```bpmnscript process order-fulfillment { @@ -644,13 +861,13 @@ process order-fulfillment { } ``` -Exactly one branch of the pair above ever runs. -Operaton starts every catch behind this gateway with a start behavior that cancels its siblings the moment one of them fires, so whichever of `ShipOrder` or `ChaseCustomer` goes first is the only one that does. -A `condition` branch is the one exception to "waits": its expression is checked the moment the statement is reached, so a condition already true wins immediately, without the gateway waiting on anything at all. +Exactly one branch runs: when one trigger fires, the engine cancels the others. +A `condition` branch is checked as soon as the statement is reached, so a condition that already holds wins at once. +Two branches waiting on the same message or signal name are an error. ### Starting on an event -A process's own start may carry a trigger, the same clause `on` and `await` already use: a soft trigger word followed by its payload. +A process's own `start` can carry a trigger: `message`, `signal`, `timer`, or `condition`, with the payload it takes elsewhere. ```bpmnscript process order-intake { @@ -658,20 +875,17 @@ process order-intake { user ConfirmOrder(label: "Confirm the order", assignee: "demo") - emit message NotifyWarehouse("WarehouseNotified") + emit message NotifyWarehouse("WarehouseNotified", class: "com.example.demo.LogDelegate") - throw message OrderAcknowledged("OrderAcknowledged") + throw message OrderAcknowledged("OrderAcknowledged", class: "com.example.demo.LogDelegate") } ``` -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. +A message start's name has to be fixed text, since a process that hasn't started has no variables to evaluate an expression against. +`error`, `escalation`, and `compensation` on a process start are errors, because the engine would ignore them and start the process as if no trigger were written. +A start inside a subprocess, an attempt block, or a handler body takes no trigger, `initiator`, or `form`, since its container enters it. -A process may carry more than one `start`, written as flat siblings, so one process is entered by hand and by a correlated message alike. +A process can have several starts, so one process is started by hand and by a message alike: ```bpmnscript process support-ticket { @@ -684,20 +898,22 @@ process support-ticket { } ``` -A `start` opens a chain and takes no incoming flow, so it sits first, right after another `start`, or after a statement that always ends or redirects the flow, such as an `end`, a `throw`, or a `goto`. -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. +A `start` takes no incoming flow, so it sits first, after another `start`, or after a statement that ends or redirects the flow, such as an `end`, a `throw`, or a `goto`. +Starts written back to back share the chain that follows, and a start after an `end` opens its own chain, which reaches shared steps by `goto`. +A start after a step whose flow still runs on is an error. -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. -The engine offers a start form only on the default start, the plain or timer one, so a `form` block on any other start is parsed and never shown. +The engine limits how many starts a scope has, and each limit is an error: + +- A subprocess, attempt block, or handler body has exactly one start, written first. +- A process has one plain or timer start, one message or signal start per name, and one condition start per condition. +- A body that doesn't open with a `start` gets a plain one from the compiler, which counts, so a plain or timer start further down is a second one. + +A process whose starts all carry a message, signal, or condition trigger can't be started by key, so it draws a warning. +The engine shows a start form only on the plain or timer start, so a `form` on any other start draws a warning too. ### Ending every path -`end terminate` stops every running path of its own scope at once, rather than raising something another handler could catch, which is why it sits on `end` instead of on `throw` and why it keeps the label a diagram's terminate node deserves, where a throw drops the label. +`end X terminate` stops every running path in its scope at once ([ADR-0025](../../docs/decisions/0025-events-at-the-process-boundary.md)). ```bpmnscript process stock-alert { @@ -717,20 +933,14 @@ process stock-alert { } ``` -A branch that reaches `terminate` never rejoins the fork it came from, so the join can end up with a single incoming flow from the branch that fell through instead, which Operaton runs as a plain pass-through rather than a wait. -`terminate` is one of the two words an end carries rather than raises, and `cancel` is the other, see [Giving a block of work up](#giving-a-block-of-work-up). +A branch ending in `terminate` never reaches the join, so the join waits only for the branches that do. +`terminate` and `cancel` are the two triggers an `end` carries; any other is an error, since every other kind is raised with `throw`. ### Giving a block of work up -`attempt X { }` is a block of work that can be given up as a unit. -It is the `subprocess` statement under a second head, so it nests, takes a label, a repeat clause, settings, and every handler a subprocess takes. -Three pieces make one construct: the block, an `end cancel` inside it that gives the block up, and an `on : cancel { }` beside it that says what happens next. -Neither cancel piece is usable without the block. - -A cancel end gives up the block it sits in, and "in" means the container rather than the nearest braces. -An `if`, a `while`, a `do`, a `parallel` branch, or an `await` branch inside the block is still the block, and so is the body of a handler attached to one of the block's steps, which shares its host's container. -A nested `subprocess`, a host-less handler, and the process itself are containers of their own, so a cancel end written in one of those is rejected. -The handler attaches to the block by name rather than opening on its own, which is why a host-less `on cancel` is rejected too: BPMN has no cancel start event for one to open on. +`attempt X { }` is a block of work that can be given up as a unit ([ADR-0029](../../docs/decisions/0029-work-that-can-be-given-up.md)). +It is a `subprocess` under another keyword, so it nests and takes a label, a repetition, settings, and handlers. +Inside it, `end X cancel` gives the block up, and beside it, `on X: cancel { }` says what happens next. ```bpmnscript process seat-booking { @@ -766,132 +976,52 @@ process seat-booking { } ``` -Giving the block up runs the undo blocks of the steps inside it that had already finished, newest first, and only when those have run does the flow reach the handler. -`HoldSeat` carries an `on compensation` block, so the seat is back on sale before the customer hears the apology. -`ChargeCard` carries none, so it stays completed and nothing reverses it. -A block whose finished steps carry no undo block has nothing to undo and goes straight to the handler. +Giving the block up runs the undo blocks of the steps inside it that had finished, newest first, then the handler. +Here `HoldSeat` has an undo block, so the seat is released before the apology; `ChargeCard` has none, so nothing reverses it. +An enclosing scope keeps whatever it finished before the block started. +An `attempt` is not a database transaction: a charge taken or a message sent is reversed only by the undo blocks you write. -The undo reaches the block's own finished steps and nothing else. -An enclosing scope keeps whatever it had finished before the block started. -An `attempt` is not a transaction in the database sense: Operaton runs a `bpmn:transaction` through the very behavior class it gives an ordinary sub-process, so a charge already taken or a warehouse already told is reversed only by the undo blocks the author wrote. +A cancel end gives up the block whose container it is in. +An `if`, a loop, a `parallel` or `await` branch inside the block, and a handler attached to one of its steps are still the block; a nested `subprocess`, a host-less handler, and the process are not, so a cancel end there is an error. +A host-less `on cancel` is an error, and so is a cancel handler on anything but an `attempt`. -A cancel end whose block carries no handler deploys and then stops with an error the first time a run reaches that end, because parsing the handler is what wires the two together. -That is why writing one half of the pair without the other is a warning rather than an error, in both directions: a cancel handler on a block nothing inside gives up is a path no run can take. -A block carrying neither half draws no diagnostic: an `attempt` with no cancel anywhere is an ordinary block of work that happens to serialize as `bpmn:transaction`, which is what lets an imported one round-trip unchanged. +A cancel end in a block with no cancel handler deploys and then fails when a run reaches it, and a cancel handler on a block nothing gives up can never run, so either half without the other draws a warning. +An `attempt` with neither is an ordinary block of work. -`attempt` is a keyword like every other statement head, so the word cannot name anything else in a process; `cancel` is an ordinary identifier, like the other trigger words. +## Reserved words -### Contextual words +These words are keywords and can't be used as a plain name; write a step's name in backticks and a variable as `"${...}"`: -Only `on`, `throw`, `emit`, `alongside`, and `await` are reserved keywords in the event layer. -The trigger kinds, the timer particles `after`/`at`/`every`, the field names `code`/`message`, the form field type `enum`, the member directions `input`/`output`/`field`/`property`, and the `when` of an error mapping are deliberately ordinary identifiers, so `var message: string`, `var enum: string`, `var timer: number`, and a task named `every` all parse like any other identifier. -The `error` heading a mapping is the same soft trigger word every code site writes. -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. +`alongside` `any` `attempt` `await` `boolean` `call` `date` `decide` `do` `each` `else` `emit` `end` `false` `for` `form` `goto` `if` `in` `json` `local` `null` `number` `on` `out` `parallel` `process` `receive` `script` `send` `sequentially` `service` `start` `step` `string` `subprocess` `throw` `true` `until` `user` `var` `while` -### 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, `"${...}"` 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`. +Everything else is an ordinary name that has a special meaning only in position: the trigger words, `terminate`, `fail`, the timer particles `after`, `at`, and `every`, the binding fields `code` and `message`, the form type `enum`, the member words `input`, `output`, `field`, and `property`, `when`, every setting key, and every listener event but `start` and `end`. +So `var message: string`, `var timer: number`, and a step named `every` are all legal, and the editor highlights and completes these words only where they carry meaning. +Using a reserved word as a name, as in `if (date > deadline)`, is an error that names the word and suggests the `"${...}"` form. ## Diagnostics -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, 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 `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. - `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 (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` 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 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, 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 = `. - -## What `goto` can reach - -A custom `ScopeProvider` (`src/bpmn-script-scope-provider.ts`) resolves `goto` targets, and a hosted handler's `host` reference, against the steps of their own nearest enclosing container: the `process`, the `subprocess`, or the host-less `on` handler body they sit in, at any block-nesting depth within it. - -A `goto` therefore never resolves into another process, into a sibling or nested `subprocess`, into or out of a host-less handler's body, or out to a parent container, even when both are declared in the same file. -A `subprocess` statement is itself a valid target, resolved from its own container like any other step, and so is an `await` that carries a name, unless its trigger is `link`: a link catch is entered by `emit link` of the same name and by nothing else. -An `on` handler has no name, so it's never a target. -This mirrors BPMN's rule that a sequence flow cannot cross a sub-process boundary, and a host-less handler is a kind of sub-process: its steps only run once its event fires. - -A hosted handler is the one exception. -Its body is not a container of its own, so the container walk skips past it and a `goto` crosses freely in both directions between the body and the main flow, which is also how the handler's `host` reference resolves. -One consequence is easy to miss while authoring: a host-less handler written inside a hosted handler's body guards the whole surrounding container, not just that escape path. - -A cross-boundary `goto` or an out-of-scope `host` fails to resolve, and a custom `Linker` (`src/bpmn-script-linker.ts`) replaces Langium's generic "could not resolve reference" with a boundary explanation whenever the name exists elsewhere in the process: - -```text -'X' is inside subprocess 'Y'; a goto cannot cross a sub-process boundary. -'X' is inside the 'on error(PAYMENT_FAILED)' handler; a goto cannot cross an event handler boundary: an event handler's steps run only when its event fires. -'X' is outside subprocess 'Y'; a boundary event attaches to an activity in its own scope. -``` - -A handler is named by its header, since it has no name of its own. -When the name exists nowhere at all, the generic message is left alone. - -Using a reserved keyword where an identifier belongs, as in `if (date > deadline)`, is caught by a custom `ParserErrorMessageProvider` (`src/bpmn-script-parser-error-message-provider.ts`), which names the offending word and points at the quoted `"${...}"` fallback instead of surfacing Chevrotain's token-mismatch message. +The validator reports errors, which stop `build`, and warnings, which don't. +A message names the problem and, where it can, the fix: `emit error` points at `throw error`, and an unknown key names the element it is not valid on. +Most rules mirror a check Operaton makes when it deploys or runs a process, so the mistake shows in the editor instead of in the engine ([ADR-0013](../../docs/decisions/0013-validator-mirrors-parse-time-rules.md)); [`src/bpmn-script-validator.ts`](src/bpmn-script-validator.ts) holds the exact rules and wording. + +Each rule is described with the construct it governs: + +| Area | Where | +| ------------------------------------------ | -------------------------------------------------------------- | +| names and labels | [Names and labels](#names-and-labels) | +| variables, JUEL words, and condition types | [Variables](#variables), [Expressions](#expressions) | +| empty bodies, unreachable steps | [Statements](#statements) | +| header values | [The process header](#the-process-header) | +| tasks, bindings, and forms | [Tasks](#tasks) | +| keys, engine settings, and unkeyed values | [Engine settings](#engine-settings) | +| parameters, fields, properties, listeners | [What an element carries](#what-an-element-carries) | +| `else fail`, parallel branches, `goto` | [Control flow](#control-flow) | +| gateway settings | [Job settings on a gateway](#job-settings-on-a-gateway) | +| calls and repetition | [Call activities](#call-activities), [Repetition](#repetition) | +| triggers, handlers, timers, starts, links | [Events](#events) | +| reserved words | [Reserved words](#reserved-words) | + +A trigger word outside the set its verb accepts, or a payload that doesn't fit its trigger, is an error naming the legal choices. ## Public API @@ -944,4 +1074,4 @@ npm test | `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`) | +| `syntaxes/` | TextMate grammar (git-ignored, generated by `langium generate` and adjusted by `textmate-postprocess.mjs`); `packages/extension` layers an injection grammar over it for code inside a fenced script | diff --git a/packages/language/package.json b/packages/language/package.json index 82fc36f..04bfb12 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,7 +1,7 @@ { "name": "@bpmn-script/language", "description": "Langium grammar, validator and language server for BPMNscript", - "version": "0.0.1", + "version": "0.1.0", "license": "Apache-2.0", "repository": { "type": "git", @@ -33,7 +33,8 @@ } }, "scripts": { - "clean": "shx rm -fr *.tsbuildinfo out", + "prepack": "node -e \"fs.cpSync('../../LICENSE', 'LICENSE')\"", + "clean": "node -e \"for (const f of [...fs.globSync('*.tsbuildinfo'), 'out']) fs.rmSync(f, { recursive: true, force: true })\"", "build": "npm run langium:generate && tsc -p tsconfig.src.json", "build:clean": "npm run clean && npm run build", "langium:generate": "langium generate && node textmate-postprocess.mjs", @@ -42,11 +43,11 @@ "test": "vitest run" }, "dependencies": { - "langium": "^4.3.1", + "langium": "^4.4.0", "vscode-languageserver-types": "^3.18.3" }, "devDependencies": { - "langium-cli": "^4.3.0", - "vitest": "^4.1.11" + "langium-cli": "^4.4.0", + "vitest": "^5.0.5" } } diff --git a/packages/language/src/bpmn-script-completion.ts b/packages/language/src/bpmn-script-completion.ts index 6660ea5..a4dcae1 100644 --- a/packages/language/src/bpmn-script-completion.ts +++ b/packages/language/src/bpmn-script-completion.ts @@ -1,6 +1,7 @@ import { AstUtils, GrammarAST, + GrammarUtils, isCompositeCstNode, isLeafCstNode, type AstNode, @@ -14,6 +15,7 @@ import { DefaultCompletionProvider, type CompletionAcceptor, type CompletionContext, + type CompletionValueItem, type NextFeature, } from 'langium/lsp'; import { @@ -53,6 +55,7 @@ import { } from './bpmn-script-validator.js'; import { isStructuralParenKey, + payloadDocumentationKeyOf, payloadItemOf, settingsOf, timerParticleOf, @@ -70,6 +73,7 @@ import { CATCH_TRIGGERS, DECLARED_CODE_TRIGGERS, DECISION_RESULT_MAPPINGS, + ELSE_FAIL_WORD, EMIT_TRIGGERS, END_TRIGGERS, ENGINE_KEYS, @@ -96,6 +100,7 @@ import { ON_TRIGGERS, OUTPUT_DIRECTION, parameterDirectionsFor, + printName, PROCESS_HEADER_KEYS, PROPERTY_DIRECTION, RACE_TRIGGERS, @@ -109,6 +114,7 @@ import { TRIGGER_PAYLOAD, TYPE_BINDING_KEY, TYPE_BINDING_VALUES, + VALUE_DOCUMENTATION_KEYS, VAR_TYPES, type EngineKey, } from './vocabulary.js'; @@ -130,6 +136,24 @@ const subscriptionChoices = (triggers: readonly string[]): string => .filter((word) => namesACode(word) && !DECLARED_CODE_TRIGGERS.has(word)) .join(','); +/** A code is written bare, a subscription name quoted. */ +const triggerForms = ( + word: string, + triggers: readonly string[], + body = '', +): StructureForm[] => [ + { + label: word, + insertText: + word + ' ${1|' + declaredCodeChoices(triggers) + '|}(${2:CODE})' + body, + }, + { + label: `${word} message`, + insertText: + word + ' ${1|' + subscriptionChoices(triggers) + '|}("${2:NAME}")' + body, + }, +]; + /** A keyword absent here keeps Langium's bare-keyword item. */ const STRUCTURE_SNIPPETS: Readonly< Record @@ -161,48 +185,10 @@ const STRUCTURE_SNIPPETS: Readonly< ], subprocess: 'subprocess ${1:id} {\n\t$0\n}', attempt: 'attempt ${1:id} {\n\t$0\n}', - // A code is written bare, a subscription name quoted; timer and condition are - // scaffolded at the trigger word. - on: [ - { - label: 'on', - insertText: - 'on ${1|' + - declaredCodeChoices(ON_TRIGGERS) + - '|}(${2:CODE}) {\n\t$0\n}', - }, - { - label: 'on message', - insertText: - 'on ${1|' + - subscriptionChoices(ON_TRIGGERS) + - '|}("${2:NAME}") {\n\t$0\n}', - }, - ], - throw: [ - { - label: 'throw', - insertText: - 'throw ${1|' + declaredCodeChoices(THROW_TRIGGERS) + '|}(${2:CODE})', - }, - { - label: 'throw message', - insertText: - 'throw ${1|' + subscriptionChoices(THROW_TRIGGERS) + '|}("${2:NAME}")', - }, - ], - emit: [ - { - label: 'emit', - insertText: - 'emit ${1|' + declaredCodeChoices(EMIT_TRIGGERS) + '|}(${2:CODE})', - }, - { - label: 'emit message', - insertText: - 'emit ${1|' + subscriptionChoices(EMIT_TRIGGERS) + '|}("${2:NAME}")', - }, - ], + // Timer and condition are scaffolded at the trigger word. + on: triggerForms('on', ON_TRIGGERS, ' {\n\t$0\n}'), + throw: triggerForms('throw', THROW_TRIGGERS), + emit: triggerForms('emit', EMIT_TRIGGERS), await: [ { label: 'await', @@ -252,6 +238,12 @@ const engineSnippets = ( const SETTING_SNIPPETS: Readonly> = { label: 'label: "${1:label}"', documentation: 'documentation: "${1:documentation}"', + ...Object.fromEntries( + Object.values(VALUE_DOCUMENTATION_KEYS).map((key) => [ + key, + `${key}: "\${1:documentation}"`, + ]), + ), ...engineSnippets((key) => key), ...engineSnippets(joinSettingKey), ...engineSnippets( @@ -387,15 +379,60 @@ const payloadOpen = (owner: AstNode, context: CompletionContext): boolean => function conditionSlotOpen(context: CompletionContext): boolean { const owner = owningElement(context); - const trigger = owner === undefined ? undefined : triggerWordOf(owner); + if (owner === undefined) return false; + const trigger = triggerWordOf(owner); return ( - owner !== undefined && trigger !== undefined && TRIGGER_PAYLOAD[trigger]?.parens === 'condition' && !payloadWritten(owner, context) ); } +/** + * The items written inside the parentheses that hold a condition when the + * caret is there, where the validator accepts the condition's documentation + * alone. + */ +function conditionItemsAt(context: CompletionContext): ParenItem[] | undefined { + for ( + let node: AstNode | undefined = context.node; + node; + node = node.$container + ) { + const owner = node as { + condition?: AstNode; + conditionItems?: ParenItem[]; + completion?: AstNode; + completionItems?: ParenItem[]; + }; + for (const [value, items] of [ + [owner.condition, owner.conditionItems], + [owner.completion, owner.completionItems], + ] as const) { + const start = value?.$cstNode?.end; + if (items === undefined || start === undefined) continue; + const close = GrammarUtils.findNodesForKeyword(node.$cstNode, ')').find( + (paren) => paren.offset >= start, + ); + if ( + close !== undefined && + context.offset > start && + context.offset <= close.offset + ) { + return items; + } + } + } + return undefined; +} + +function conditionItemForms(items: ParenItem[]): StructureForm[] { + const key = VALUE_DOCUMENTATION_KEYS.condition; + return settingsOf(items).some((setting) => setting.key === key) + ? [] + : settingForms([key]); +} + /** The only flag any rule lists is the handler's `alongside`. */ function flagWordsFor(node: AstNode): readonly string[] { const trigger = triggerWordOf(node); @@ -410,7 +447,10 @@ function settingFormsFor(node: AstNode): StructureForm[] | undefined { return settingForms(PROCESS_HEADER_KEYS); } if (isListener(node)) { - return settingForms(LISTENER_BINDING_KEYS); + return settingForms([ + ...LISTENER_BINDING_KEYS, + ...(node.event === 'timeout' ? [VALUE_DOCUMENTATION_KEYS.timer] : []), + ]); } if (isFormField(node)) { return settingForms(FORM_FIELD_SETTING_KEYS); @@ -442,10 +482,26 @@ function settingFormsFor(node: AstNode): StructureForm[] | undefined { ...own, ...ENGINE_KEYS, ...(rule.repeats && isRepeated(node) ? RUN_ENGINE_KEYS : []), + ...valueDocumentationKeysOf(node), ]), ]; } +/** Offered only beside the value each documents. */ +function valueDocumentationKeysOf(node: AstNode): string[] { + const trigger = triggerWordOf(node); + const payloadKey = + trigger === undefined ? undefined : payloadDocumentationKeyOf(trigger); + const clause = node as { cardinality?: unknown; element?: string }; + return [ + ...(payloadKey === undefined ? [] : [payloadKey]), + ...(clause.cardinality === undefined + ? [] + : [VALUE_DOCUMENTATION_KEYS.count]), + ...(clause.element === undefined ? [] : [VALUE_DOCUMENTATION_KEYS.each]), + ]; +} + const writtenKeysOf = (owner: AstNode): ReadonlySet => new Set(settingsOf(itemsOf(owner)).map((setting) => setting.key)); @@ -697,9 +753,23 @@ const STRUCTURE_DETAILS: Readonly> = { for: 'how often the preceding step runs', }; -const FLAG_DETAIL = 'BPMNscript flag'; -const FORM_TYPE_DETAIL = 'BPMNscript form type'; -const SETTING_VALUE_DETAIL = 'BPMNscript setting value'; +const DIRECTION_DETAILS: Readonly> = { + [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]: + 'a value Tasklist or a worker reads off the step, never a variable', +}; + +/** + * A field lowers to `stringValue` or an expression and a property to `value`, + * so both are quoted. + */ +const DIRECTION_VALUES: Readonly> = { + [FIELD_DIRECTION]: '"${2:value}"', + [PROPERTY_DIRECTION]: '"${2:value}"', +}; export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { private readonly variables: VariableSymbolProvider; @@ -714,9 +784,6 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { next: NextFeature, acceptor: CompletionAcceptor, ): MaybePromise { - // 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 ( next.property === 'key' && @@ -725,9 +792,13 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { ) { // 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) ?? []); + const conditionItems = conditionItemsAt(context); + const forms = + conditionItems !== undefined + ? conditionItemForms(conditionItems) + : payloadOpen(owner, context) + ? timerKeyForms(owner) + : (settingFormsFor(owner) ?? []); for (const form of forms) { this.acceptSnippet(context, acceptor, { ...form, @@ -742,7 +813,12 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { AstUtils.getContainerOfType(context.node, isFormField) ) { if (!GrammarAST.isKeyword(next.feature)) { - this.acceptWords(context, acceptor, FORM_FIELD_TYPES, FORM_TYPE_DETAIL); + this.acceptWords( + context, + acceptor, + FORM_FIELD_TYPES, + 'BPMNscript form type', + ); } return; } @@ -755,7 +831,12 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { const choices = key === undefined ? undefined : SETTING_VALUE_CHOICES[key]; if (choices) { - this.acceptWords(context, acceptor, choices, SETTING_VALUE_DETAIL); + this.acceptWords( + context, + acceptor, + choices, + 'BPMNscript setting value', + ); return; } } @@ -777,6 +858,15 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { this.acceptListenerEvents(context, acceptor, owner); return; } + if (next.property === 'fail') { + this.acceptWords( + context, + acceptor, + [ELSE_FAIL_WORD], + 'stop the run with an error when no condition holds', + ); + return; + } if (next.property === 'kind' && next.type === CodeDecl.$type) { for (const form of CODE_DECLARATION_FORMS) { this.acceptSnippet(context, acceptor, form); @@ -793,6 +883,9 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { } return; } + // 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; if (next.property === 'trigger' && nodeType) { const triggers = STATEMENT_TRIGGERS[nodeType]; if (triggers) { @@ -836,6 +929,18 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { ); } + /** A name the plain form cannot spell is offered as written, in backticks. */ + protected override createReferenceCompletionItem( + nodeDescription: AstNodeDescription, + refInfo: ReferenceInfo, + context: CompletionContext, + ): CompletionValueItem { + return { + ...super.createReferenceCompletionItem(nodeDescription, refInfo, context), + label: printName(nodeDescription.name), + }; + } + protected override completionForCrossReference( context: CompletionContext, next: NextFeature, @@ -986,37 +1091,16 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { } } - private static readonly DIRECTION_DETAILS: Readonly> = - { - [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]: - 'a value Tasklist or a worker reads off the step, never a variable', - }; - - /** - * 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}"', - [PROPERTY_DIRECTION]: '"${2:value}"', - }; - private acceptParameterDirections( context: CompletionContext, acceptor: CompletionAcceptor, directions: readonly string[], ): void { for (const direction of directions) { - const value = - BpmnScriptCompletionProvider.DIRECTION_VALUES[direction] ?? - '${2:value}'; + const value = DIRECTION_VALUES[direction] ?? '${2:value}'; this.acceptSnippet(context, acceptor, { label: direction, - detail: BpmnScriptCompletionProvider.DIRECTION_DETAILS[direction], + detail: DIRECTION_DETAILS[direction], insertText: `${direction} \${1:name} = ${value}`, }); } @@ -1077,7 +1161,7 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { !payloadOpen(owner, context) && flagWordsFor(owner).includes(keyword.value) ) { - this.acceptWords(context, acceptor, [keyword.value], FLAG_DETAIL); + this.acceptWords(context, acceptor, [keyword.value], 'BPMNscript flag'); } return; } diff --git a/packages/language/src/bpmn-script-hover.ts b/packages/language/src/bpmn-script-hover.ts index c90cf43..9696de1 100644 --- a/packages/language/src/bpmn-script-hover.ts +++ b/packages/language/src/bpmn-script-hover.ts @@ -21,7 +21,7 @@ import { EVENT_MESSAGE_FIELD, } from './vocabulary.js'; import { isVariableSymbolNode } from './bpmn-script-references.js'; -import { declaredCodeOf, settingsOf } from './paren-items.js'; +import { settingsOf } from './paren-items.js'; function literalSetting(items: ParenItem[], key: string): string | undefined { const setting = settingsOf(items).find((item) => item.key === key); @@ -40,12 +40,9 @@ function namedStatementHover(node: NamedStatement): string { function codeDeclHover(decl: CodeDecl): string { const article = /^[aeiou]/i.test(decl.kind) ? 'an' : 'a'; let line = `${article} ${decl.kind} '${decl.name}'`; - // `declaredCodeOf` falls back to the name; hover quotes only a written setting. - 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}"`; + // Not `declaredCodeOf`, which falls back to the name; an empty code is not quoted. + const code = literalSetting(decl.items, EVENT_CODE_FIELD); + if (code) line += ` with code "${code}"`; const message = literalSetting(decl.items, EVENT_MESSAGE_FIELD); if (message !== undefined) line += `: ${message}`; return line; diff --git a/packages/language/src/bpmn-script-linker.ts b/packages/language/src/bpmn-script-linker.ts index 6e20fc4..19a1520 100644 --- a/packages/language/src/bpmn-script-linker.ts +++ b/packages/language/src/bpmn-script-linker.ts @@ -27,6 +27,7 @@ import { type NamedStatement, } from './bpmn-script-scope-provider.js'; import { codeTriggerOf, payloadTextOf } from './paren-items.js'; +import { diagnosticText, printName } from './vocabulary.js'; /** First match wins: duplicate names are a validator error. */ function findNamedStatement( @@ -106,28 +107,30 @@ export class BpmnScriptLinker extends DefaultLinker { targetDescription?: AstNodeDescription, ): LinkingError { const source = refInfo.container; + const name = refInfo.reference.$refText; const codeTrigger = codeTriggerOf(source); if (codeTrigger !== undefined) { - const name = refInfo.reference.$refText; // Advising a second declaration under the other kind would lead to the duplicate-name error. const declaredKind = declaredCodeKind(source, name); const message = declaredKind === undefined - ? `'${name}' is not declared. Add '${codeTrigger} ${name}' to the process.` - : `'${name}' is declared as an ${declaredKind}, not an ${codeTrigger}.`; + ? diagnosticText( + `'${name}' is not declared`, + `help: add '${codeTrigger} ${printName(name)}' to the process`, + ) + : `'${name}' is declared as an ${declaredKind}, not an ${codeTrigger}`; return { info: refInfo, message }; } const isHost = isOnHandler(source) && refInfo.property === 'host'; 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, 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.`; + ? `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); @@ -138,16 +141,23 @@ export class BpmnScriptLinker extends DefaultLinker { if (located) { const { phrase, crossesHandler } = located; const boundary = isHost - ? `a boundary event attaches to an activity in its own scope.` + ? ['note: a boundary event attaches to an activity in its own scope'] : crossesHandler - ? `a goto cannot cross an event handler boundary: an event handler's steps run only when its event fires.` - : `a goto cannot cross a subprocess boundary.`; + ? [ + 'note: a goto cannot cross an event handler boundary', + "note: an event handler's steps run only when its event fires", + ] + : ['note: a goto cannot cross a subprocess boundary']; return { info: refInfo, - message: `'${refInfo.reference.$refText}' is ${phrase}; ${boundary}`, + message: diagnosticText(`'${name}' is ${phrase}`, ...boundary), }; } } - return super.createLinkingError(refInfo, targetDescription); + // Langium's own wording names the grammar type the reference expects. + return { + ...super.createLinkingError(refInfo, targetDescription), + message: `nothing named '${name}' is in reach here`, + }; } } diff --git a/packages/language/src/bpmn-script-module.ts b/packages/language/src/bpmn-script-module.ts index 2efa864..06dc6bc 100644 --- a/packages/language/src/bpmn-script-module.ts +++ b/packages/language/src/bpmn-script-module.ts @@ -1,4 +1,4 @@ -import { type Module, inject } from 'langium'; +import { type Module, createGrammarConfig, inject } from 'langium'; import { createDefaultModule, createDefaultSharedModule, @@ -24,11 +24,17 @@ import { BpmnScriptCompletionProvider } from './bpmn-script-completion.js'; import { BpmnScriptDocumentValidator } from './bpmn-script-document-validator.js'; import { BpmnScriptScopeProvider } from './bpmn-script-scope-provider.js'; import { BpmnScriptLinker } from './bpmn-script-linker.js'; -import { BpmnScriptParserErrorMessageProvider } from './bpmn-script-parser-error-message-provider.js'; +import { + BpmnScriptLexerErrorMessageProvider, + 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 { + BpmnScriptReferences, + BpmnScriptRenameProvider, +} from './bpmn-script-references.js'; import { BpmnScriptDocumentSymbolProvider, BpmnScriptNodeKindProvider, @@ -52,7 +58,13 @@ export const BpmnScriptModule: Module< parser: { ParserErrorMessageProvider: (services) => new BpmnScriptParserErrorMessageProvider(services), + LexerErrorMessageProvider: () => new BpmnScriptLexerErrorMessageProvider(), ValueConverter: () => new BpmnScriptValueConverter(), + // A caret on a name's opening backtick still names it. + GrammarConfig: (services) => ({ + ...createGrammarConfig(services), + nameRegexp: /^[\w\p{L}`]$/u, + }), }, references: { VariableSymbolProvider: () => new DefaultVariableSymbolProvider(), @@ -70,6 +82,7 @@ export const BpmnScriptModule: Module< SemanticTokenProvider: (services) => new BpmnScriptSemanticTokenProvider(services), HoverProvider: (services) => new BpmnScriptHoverProvider(services), + RenameProvider: (services) => new BpmnScriptRenameProvider(services), DocumentSymbolProvider: (services) => new BpmnScriptDocumentSymbolProvider(services), }, 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 d9de744..7e509e5 100644 --- a/packages/language/src/bpmn-script-parser-error-message-provider.ts +++ b/packages/language/src/bpmn-script-parser-error-message-provider.ts @@ -1,51 +1,51 @@ /** - * 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. + * Plain wording in place of Chevrotain's token-type lists; 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 { + DefaultLexerErrorMessageProvider, LangiumParserErrorMessageProvider, type LangiumCoreServices, } from 'langium'; import { DECLARED_CODE_TRIGGERS, + diagnosticText, + ELSE_FAIL_WORD, + formatPlainWordList, formatWordList, IO_DIRECTIONS, reservedWordsOf, } from './vocabulary.js'; const ID_TOKEN_NAME = 'ID'; - -/** - * 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'; -const FOR_KEYWORD_TOKEN_NAME = 'for'; const EOF_TOKEN_NAME = 'EOF'; +const ELSE_KEYWORD = 'else'; -/** - * A mistyped statement keyword parses as a code declaration and fails at the - * name slot; a real declaration word is excluded. - */ -const CODE_DECL_RULE_NAME = 'CodeDecl'; +const STEP_KEYWORD_NOTE = + 'note: every step starts with a keyword such as ' + + "'start', 'user', 'service', 'if', 'on', 'throw', or 'emit'"; -/** - * 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 = ')'; +/** What each terminal means to an author; a keyword token is named after its text. */ +const TERMINAL_NOUNS: Readonly> = { + ID: 'a name', + INT: 'a number', + DECIMAL: 'a number', + STRING: 'a quoted string', + RAW_TEMPLATE: 'a "${...}" expression', + QUOTED_NAME: 'a name', + FENCED_SCRIPT: 'a fenced script', + EOF: 'the end of the file', +}; + +/** These three starting the alternatives mean any value fits. */ +const VALUE_START_TOKENS = ['ID', 'INT', 'STRING']; -const STEP_KEYWORD_HINT = - 'every step starts with a keyword such as ' + - "'start', 'user', 'service', 'if', 'on', 'throw', 'emit', ..."; +/** More choices than this read as noise, so the message names the found token instead. */ +const MAX_LISTED_CHOICES = 4; const WORD_SHAPED = /^[A-Za-z_]/; @@ -61,6 +61,59 @@ type MismatchTokenOptions = Parameters< type NoViableAltOptions = Parameters< LangiumParserErrorMessageProvider['buildNoViableAltMessage'] >[0]; +type EarlyExitOptions = Parameters< + LangiumParserErrorMessageProvider['buildEarlyExitMessage'] +>[0]; +type NotAllInputParsedOptions = Parameters< + LangiumParserErrorMessageProvider['buildNotAllInputParsedMessage'] +>[0]; +type Token = MismatchTokenOptions['actual']; +type TokenType = MismatchTokenOptions['expected']; + +function tokenNoun(tokenName: string): string { + return TERMINAL_NOUNS[tokenName] ?? `'${tokenName.replace(/:KW$/, '')}'`; +} + +/** A token spanning lines, such as a fenced script, is named by its kind so the headline stays one line. */ +function tokenPhrase(token: Token): string { + return token.image.includes('\n') + ? tokenNoun(token.tokenType.name) + : `'${token.image}'`; +} + +function foundPhrase(token: Token | undefined): string { + return token === undefined || token.tokenType.name === EOF_TOKEN_NAME + ? 'the end of the file' + : tokenPhrase(token); +} + +/** Chevrotain hands a placeholder with an empty image when nothing came before. */ +function afterPhrase(previous: Token | undefined): string { + return previous?.image ? ` after ${tokenPhrase(previous)}` : ''; +} + +/** The first token of every path, described once each. */ +function choiceNouns(paths: readonly (readonly TokenType[])[]): string[] { + const names = [ + ...new Set(paths.flatMap((path) => (path[0] ? [path[0].name] : []))), + ]; + if (VALUE_START_TOKENS.every((name) => names.includes(name))) { + return ['a value']; + } + return [...new Set(names.map(tokenNoun))]; +} + +function expectedOrUnexpected( + paths: readonly (readonly TokenType[])[], + actual: Token | undefined, + previous: Token | undefined, +): string { + const nouns = choiceNouns(paths); + if (nouns.length > 0 && nouns.length <= MAX_LISTED_CHOICES) { + return `expected ${formatPlainWordList(nouns)}${afterPhrase(previous)}`; + } + return `unexpected ${foundPhrase(actual)}${afterPhrase(previous)}`; +} export class BpmnScriptParserErrorMessageProvider extends LangiumParserErrorMessageProvider { private readonly services: LangiumCoreServices; @@ -73,107 +126,114 @@ 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 === ')') { + return diagnosticText( + `expected ')' before ${foundPhrase(actual)}`, + "note: 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 + // In a braced block a plain 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. + if (expected.name === '=' && rule === 'IoParameter') { + return diagnosticText( + `expected '=' after '${options.previous.image}'`, + "note: inside a block, two plain words start a parameter such as 'input name = value'", + STEP_KEYWORD_NOTE, ); } - if (expected.name === CLOSE_BRACE_TOKEN_NAME) { - if (actual.tokenType.name === VAR_KEYWORD_TOKEN_NAME) { - return this.varPlacementMessage(); - } - 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}`; + // A misplaced `var` or `for`, or a plain word after the first step, ends the + // statement list early; at `EOF` the stock message would say "found ``". + if (expected.name === '}') { + const found = actual.tokenType.name; + if (found === 'var') return VAR_PLACEMENT_MESSAGE; + if (found === 'for') return REPEAT_CLAUSE_PLACEMENT_MESSAGE; + if (found === EOF_TOKEN_NAME) return UNCLOSED_BLOCK_MESSAGE; + if (found === ID_TOKEN_NAME && rule === 'Process') { + return stepKeywordMessage(actual.image); } } if (expected.name === ID_TOKEN_NAME) { + if (options.previous.image === ELSE_KEYWORD) return ELSE_FOLLOWER_MESSAGE; if (this.isReservedWord(actual.tokenType.name)) { - return this.reservedWordMessage(actual.image); + return reservedWordMessage(actual.image); } const word = options.previous.image; - if (rule === CODE_DECL_RULE_NAME && !DECLARED_CODE_TRIGGERS.has(word)) { - return this.declarationOrStepMessage(word); + // A mistyped statement keyword parses as a code declaration and fails at + // the name slot. + if (rule === 'CodeDecl' && !DECLARED_CODE_TRIGGERS.has(word)) { + return declarationOrStepMessage(word); } - if (rule === IO_PARAMETER_RULE_NAME && !IO_DIRECTIONS.includes(word)) { - return `'${word}' is not a step keyword; ${STEP_KEYWORD_HINT}`; + if (rule === 'IoParameter' && !IO_DIRECTIONS.includes(word)) { + return stepKeywordMessage(word); } } - return super.buildMismatchTokenMessage(options); - } - - private varPlacementMessage(): string { - return ( - "A variable declaration ('var ...') must come before the first step in " + - 'the process, with the other declarations. Move it above the first ' + - 'statement.' - ); - } - - private repeatClausePlacementMessage(): string { - return ( - "A repeat clause ('for ...') attaches to the step that repeats. " + - 'The statement before it does not take one; move the clause onto ' + - 'the step that should.' - ); - } - - 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; ${STEP_KEYWORD_HINT}` - ); + if (expected.name === EOF_TOKEN_NAME) { + return outsideProcessMessage(actual); + } + return `expected ${tokenNoun(expected.name)}${afterPhrase(options.previous)}`; } override buildNoViableAltMessage(options: NoViableAltOptions): string { const actual = options.actual[0]; + if (options.previous.image === ELSE_KEYWORD) return ELSE_FOLLOWER_MESSAGE; + const rule = bareRuleName(options.ruleName); + // A `Name` is a choice of two tokens, so a bad one arrives as no viable + // alternative inside it rather than as a mismatch in the rule holding it. + // The only `Name` after a plain word is a code declaration's. + const inName = rule === 'Name'; if ( actual && this.isReservedWord(actual.tokenType.name) && this.expectsIdentifier(options.expectedPathsPerAlt) ) { - return this.reservedWordMessage(actual.image); + return inName + ? reservedNameMessage(actual.image) + : reservedWordMessage(actual.image); + } + const word = options.previous.image; + if ( + rule === 'IoParameter' && + options.previous.tokenType.name === ID_TOKEN_NAME && + !IO_DIRECTIONS.includes(word) + ) { + return stepKeywordMessage(word); + } + if ( + inName && + options.previous.tokenType.name === ID_TOKEN_NAME && + !DECLARED_CODE_TRIGGERS.has(word) + ) { + return declarationOrStepMessage(word); } if (actual && WORD_SHAPED.test(actual.image)) { const words = this.keywordAlternatives(options.expectedPathsPerAlt); if (words) { - return this.wordAlternativeMessage(actual.image, words); + return diagnosticText( + `'${actual.image}' is not a word this position takes`, + `help: write ${formatWordList(words)}`, + ); } } - return super.buildNoViableAltMessage(options); + return expectedOrUnexpected( + options.expectedPathsPerAlt.flat(), + actual, + options.previous, + ); } - private wordAlternativeMessage( - word: string, - alternatives: readonly string[], + override buildEarlyExitMessage(options: EarlyExitOptions): string { + return expectedOrUnexpected( + options.expectedIterationPaths, + options.actual[0], + options.previous, + ); + } + + override buildNotAllInputParsedMessage( + options: NotAllInputParsedOptions, ): string { - return `'${word}' is not a word this position takes; write ${formatWordList(alternatives)}.`; + return outsideProcessMessage(options.firstRedundant); } /** `undefined` unless every alternative is a single keyword. */ @@ -192,14 +252,6 @@ export class BpmnScriptParserErrorMessageProvider extends LangiumParserErrorMess return words.length > 0 ? words : undefined; } - private reservedWordMessage(word: string): string { - const rawFallback = '"${' + word + '}"'; - return ( - `'${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: ${rawFallback}.` - ); - } - private isReservedWord(tokenName: string): boolean { return reservedWordsOf(this.services.Grammar).has(tokenName); } @@ -212,3 +264,65 @@ export class BpmnScriptParserErrorMessageProvider extends LangiumParserErrorMess ); } } + +const VAR_PLACEMENT_MESSAGE = diagnosticText( + "a variable declaration ('var ...') comes after the first step", + 'note: declarations come before the first step of the process', + 'help: move it above the first statement', +); + +const REPEAT_CLAUSE_PLACEMENT_MESSAGE = diagnosticText( + "a repeat clause ('for ...') follows a statement that does not take one", + 'note: a repeat clause attaches to the step that repeats', + 'help: move the clause onto the step that should repeat', +); + +// Only `fail` follows `else` as a word, so a keyword there is no misspelt name. +const ELSE_FOLLOWER_MESSAGE = `expected '{' or '${ELSE_FAIL_WORD}' after '${ELSE_KEYWORD}'`; + +const UNCLOSED_BLOCK_MESSAGE = diagnosticText( + "expected '}' before the end of the file", + 'note: a block is still open', +); + +function outsideProcessMessage(token: Token): string { + return `unexpected ${foundPhrase(token)} outside any process`; +} + +function stepKeywordMessage(word: string): string { + return diagnosticText(`'${word}' is not a step keyword`, STEP_KEYWORD_NOTE); +} + +function declarationOrStepMessage(word: string): string { + return diagnosticText( + `'${word}' is neither a known declaration nor a step keyword`, + `note: a declaration starting with a plain word is ${formatWordList([...DECLARED_CODE_TRIGGERS])} followed by the name it declares`, + STEP_KEYWORD_NOTE, + ); +} + +function reservedNameMessage(word: string): string { + return diagnosticText( + `'${word}' is a reserved word and cannot be a plain name here`, + `help: to use '${word}' as the name, write it in backticks as '\`${word}\`'`, + ); +} + +function reservedWordMessage(word: string): string { + return diagnosticText( + `'${word}' is a reserved word and cannot be a plain name here`, + `help: to refer to a variable named '${word}', write the quoted raw expression "\${${word}}"`, + ); +} + +/** Chevrotain's own wording quotes the character between arrows and adds offsets. */ +export class BpmnScriptLexerErrorMessageProvider extends DefaultLexerErrorMessageProvider { + override buildUnexpectedCharactersMessage( + fullText: string, + startOffset: number, + length: number, + ): string { + const text = fullText.substring(startOffset, startOffset + length); + return `unexpected ${length === 1 ? 'character' : 'characters'} '${text}'`; + } +} diff --git a/packages/language/src/bpmn-script-references.ts b/packages/language/src/bpmn-script-references.ts index 6be8925..36b3a6c 100644 --- a/packages/language/src/bpmn-script-references.ts +++ b/packages/language/src/bpmn-script-references.ts @@ -18,6 +18,7 @@ import { type ReferenceDescription, type Stream, } from 'langium'; +import { DefaultRenameProvider } from 'langium/lsp'; import { isProcess, isVarRef, @@ -26,6 +27,7 @@ import { } from './generated/ast.js'; import type { BpmnScriptServices } from './bpmn-script-module.js'; import { isVariableUse } from './bpmn-script-validator.js'; +import { printName, unquoteName } from './vocabulary.js'; import type { DeclaringSite, VariableSymbolProvider, @@ -143,3 +145,39 @@ export class BpmnScriptReferences extends DefaultReferences { }; } } + +type RenameArgs = Parameters; + +/** + * A step or code renamed to text the plain form cannot spell is written in + * backticks at every site; a variable has no backtick form. + */ +export class BpmnScriptRenameProvider extends DefaultRenameProvider { + override async rename( + ...[document, params, cancelToken]: RenameArgs + ): ReturnType { + const typed = /^`.+`$/.test(params.newName) + ? unquoteName(params.newName) + : params.newName; + const newName = this.renamesVariable(document, params) + ? typed + : printName(typed); + return super.rename(document, { ...params, newName }, cancelToken); + } + + private renamesVariable( + document: LangiumDocument, + params: RenameArgs[1], + ): boolean { + const root = document.parseResult.value.$cstNode; + const leaf = + root && + CstUtils.findDeclarationNodeAtOffset( + root, + document.textDocument.offsetAt(params.position), + this.grammarConfig.nameRegexp, + ); + const [target] = leaf ? this.references.findDeclarations(leaf) : []; + return target !== undefined && isVariableSymbolNode(target); + } +} diff --git a/packages/language/src/bpmn-script-scope-provider.ts b/packages/language/src/bpmn-script-scope-provider.ts index 7758510..74b5514 100644 --- a/packages/language/src/bpmn-script-scope-provider.ts +++ b/packages/language/src/bpmn-script-scope-provider.ts @@ -86,9 +86,10 @@ export function isNamedStatement(node: AstNode): node is NamedStatement { isBusinessRuleTask(node) || isSubProcess(node) || isCallActivity(node) || - ((isThrowStatement(node) || isEmitStatement(node)) && - node.name !== undefined) || - (isIntermediateCatchEvent(node) && node.name !== undefined) + ((isThrowStatement(node) || + isEmitStatement(node) || + isIntermediateCatchEvent(node)) && + node.name !== undefined) ); } diff --git a/packages/language/src/bpmn-script-semantic-tokens.ts b/packages/language/src/bpmn-script-semantic-tokens.ts index d093f61..2f57c67 100644 --- a/packages/language/src/bpmn-script-semantic-tokens.ts +++ b/packages/language/src/bpmn-script-semantic-tokens.ts @@ -16,10 +16,12 @@ import { isEndEvent, isErrorMapping, isFormField, + isIfStatement, isIntermediateCatchEvent, isIoParameter, isListener, isOnHandler, + isParallelStatement, isRaceBranch, isSetting, isStartEvent, @@ -59,6 +61,10 @@ export class BpmnScriptSemanticTokenProvider extends AbstractSemanticTokenProvid if (node.particle) { keyword('particle'); } + } else if (isIfStatement(node) || isParallelStatement(node)) { + if (node.fail !== undefined) { + keyword('fail'); + } } else if (isErrorMapping(node)) { keyword('trigger'); keyword('when'); diff --git a/packages/language/src/bpmn-script-symbols.ts b/packages/language/src/bpmn-script-symbols.ts index ac895cd..1cb85b7 100644 --- a/packages/language/src/bpmn-script-symbols.ts +++ b/packages/language/src/bpmn-script-symbols.ts @@ -25,7 +25,8 @@ const SYMBOL_KIND_BY_TYPE: Readonly> = { export class BpmnScriptNodeKindProvider extends DefaultNodeKindProvider { override getSymbolKind(node: AstNode | AstNodeDescription): SymbolKind { - const type = 'type' in node ? node.type : node.$type; + // A VarDecl node has a `type` of its own (its declared type), so test `$type` first. + const type = '$type' in node ? node.$type : node.type; return SYMBOL_KIND_BY_TYPE[type] ?? super.getSymbolKind(node); } } diff --git a/packages/language/src/bpmn-script-validator.ts b/packages/language/src/bpmn-script-validator.ts index 0f64c47..a678213 100644 --- a/packages/language/src/bpmn-script-validator.ts +++ b/packages/language/src/bpmn-script-validator.ts @@ -6,6 +6,7 @@ import { AstUtils, + GrammarUtils, type AstNode, type ValidationAcceptor, type ValidationChecks, @@ -19,6 +20,7 @@ import type { CallActivity, CodeDecl, DoWhileStatement, + ElseIf, EmitStatement, EndEvent, Expr, @@ -34,6 +36,7 @@ import type { Model, Multiplicative, OnHandler, + ParallelBranch, ParallelStatement, ParenItem, ParenValue, @@ -136,7 +139,6 @@ import { attributeBlockRuleOf, BOOLEAN_SETTING_KEYS, BUILTIN_FIELD_NAMES, - BUILTIN_FIELD_VALIDATOR, BUILTIN_REQUIRED_FIELDS, BUSINESS_RULE_BINDING_KEYS, CALL_BINDING_VALUES, @@ -144,8 +146,10 @@ import { CATCH_TRIGGERS, DATE_PATTERN_KEY, DECLARED_CODE_TRIGGERS, + DOCUMENTATION_KEY, DECISION_MODIFIER_KEYS, DECISION_RESULT_MAPPINGS, + diagnosticText, EMIT_TRIGGERS, END_TRIGGERS, ENGINE_KEYS, @@ -171,7 +175,10 @@ import { formatPlainWordList, formatWordList, gatewayStatementRuleOf, - ID_TEXT, + ELSE_FAIL_WORD, + ENGINE_ID_TEXT, + printName, + INPUT_DIRECTION, IO_DIRECTIONS, JOIN_ENGINE_KEYS, JOIN_KEY_BY_ENGINE_KEY, @@ -206,7 +213,10 @@ import { TRIGGER_PAYLOAD, TYPE_BINDING_KEY, TYPE_BINDING_VALUES, + USER_TASK_DUE_DATE_KEYS, + USER_TASK_PRIORITY_KEY, USER_TASK_VERBATIM_KEYS, + VALUE_DOCUMENTATION_KEYS, type AttributeBlockRule, type AttributeOwner, type BuiltinTaskType, @@ -235,9 +245,9 @@ export function registerValidationChecks(services: BpmnScriptServices) { UserTask: validator.checkUserTask, ServiceTask: validator.checkServiceTaskAttributes, ScriptTask: validator.checkScriptTask, - GenericTask: validator.checkAttributeOwner, + GenericTask: validator.checkAttributeBlock, SendTask: validator.checkServiceTaskAttributes, - ReceiveTask: validator.checkAttributeOwner, + ReceiveTask: validator.checkAttributeBlock, BusinessRuleTask: validator.checkBusinessRuleTask, IfStatement: validator.checkIfStatement, WhileStatement: validator.checkWhileStatement, @@ -252,7 +262,9 @@ export function registerValidationChecks(services: BpmnScriptServices) { EmitStatement: validator.checkEmitStatement, IntermediateCatchEvent: validator.checkIntermediateCatchEvent, ParenValue: validator.checkParenValue, + ParenItem: validator.checkConditionItem, Block: validator.checkBlock, + IoParameter: validator.checkIoParameterName, }; registry.register(checks, validator); } @@ -269,8 +281,6 @@ type GatewayStatement = | ParallelStatement | RaceStatement; -const USER_PRIORITY_KEY = 'priority'; - /** * 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 @@ -283,26 +293,26 @@ export const NON_VARIABLE_ATTR_KEYS: ReadonlySet = new Set([ ...BUSINESS_RULE_BINDING_KEYS, ...DECISION_MODIFIER_KEYS, 'process', - ...USER_TASK_VERBATIM_KEYS.filter((key) => key !== USER_PRIORITY_KEY), + ...USER_TASK_VERBATIM_KEYS.filter((key) => key !== USER_TASK_PRIORITY_KEY), ...engineSpellings('retryCycle'), 'resultVariable', 'initiator', 'validator', ...Object.values(CALL_MAPPER_KEY_BY_KIND), + ...Object.values(VALUE_DOCUMENTATION_KEYS), ]); 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); -const DUE_DATE_KEYS: readonly string[] = ['dueDate', 'followUpDate']; -const DUE_DATE_KEY_SET: ReadonlySet = new Set(DUE_DATE_KEYS); +const DUE_DATE_KEY_SET: ReadonlySet = new Set(USER_TASK_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, + USER_TASK_PRIORITY_KEY, ]); const RETRY_CYCLE_ATTR_KEYS: ReadonlySet = new Set( @@ -312,7 +322,7 @@ const RETRY_CYCLE_ATTR_KEYS: ReadonlySet = new Set( /** Parsed by the engine, so a bareword or number there is unreadable to it. */ const TEXT_ATTR_KEYS: ReadonlySet = new Set([ ...RETRY_CYCLE_ATTR_KEYS, - ...DUE_DATE_KEYS, + ...USER_TASK_DUE_DATE_KEYS, ]); /** @@ -441,28 +451,38 @@ const TASK_LISTENER_EVENT_SET: ReadonlySet = new Set( * Read off the block rules so the message matches what is enforced; `attempt` * has no row of its own. */ -export const PARAMETER_HOSTS_MESSAGE = `parameters belong on ${formatPlainWordList( +export const PARAMETER_HOSTS_MESSAGE = `note: parameters belong on ${formatPlainWordList( [...Object.values(ATTRIBUTE_BLOCK_RULES), ATTEMPT_BLOCK_RULE] .filter((rule) => rule.parameters) .map((rule) => rule.description), 'and', -)}.`; +)}`; + +const noParameterHostMessage = (description: string) => + diagnosticText( + `${description} cannot declare an 'input' or 'output' parameter`, + PARAMETER_HOSTS_MESSAGE, + ); -export const REPEATED_OUTPUT_MESSAGE = - "A repeated step cannot map an 'output' parameter: the engine refuses to " + - 'deploy it (BpmnParse.checkActivityOutputParameterSupported). Move the ' + - 'mapping to a step after the repetition.'; +export const REPEATED_OUTPUT_MESSAGE = diagnosticText( + "a repeated step cannot map an 'output' parameter", + 'note: the engine refuses to deploy an output mapping on a repeated step', + 'help: move the mapping to a step after the repetition', +); /** A listener has no row of its own, so it is added back. */ -const FIELD_HOSTS_MESSAGE = `an injected field belongs on ${Object.values( +const FIELD_HOSTS_MESSAGE = `note: an injected field belongs on ${Object.values( ATTRIBUTE_BLOCK_RULES, ) .filter((rule) => rule.fields) .map((rule) => rule.description) - .join(', ')}, and on a listener.`; + .join(', ')}, and on a listener`; export const noFieldHostMessage = (description: string) => - `${capitalize(description)} cannot declare a 'field' parameter; ${FIELD_HOSTS_MESSAGE}`; + diagnosticText( + `${description} cannot declare a 'field' parameter`, + FIELD_HOSTS_MESSAGE, + ); /** * Names only the fieldless bindings written, since a listener takes neither @@ -473,11 +493,12 @@ export const fieldBindingMessage = ( written: readonly string[], takes: { keys: readonly string[]; targets: string }, ) => - `${subject} carries an injected field only under a ${formatWordList(takes.keys)} ` + - `binding: the engine injects into ${takes.targets} that binding names` + - (written.length === 0 - ? '.' - : `, and the binding written with ${formatWordList(written)} receives none.`); + diagnosticText( + `${subject} has a field but no ${formatWordList(takes.keys)} binding to inject it into`, + `note: the engine injects a field into ${takes.targets} that binding names`, + written.length > 0 && + `note: the binding written with ${formatWordList(written)} receives no fields`, + ); export const ELEMENT_FIELD_BINDINGS = { keys: FIELD_BINDING_KEYS, @@ -500,84 +521,144 @@ const EXTERNAL_HOSTS_PHRASE = `${formatPlainWordList( )} bound with 'topic'`; 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.`; + diagnosticText( + `${description} cannot declare a 'property' line`, + `note: a property line belongs on ${EXTERNAL_HOSTS_PHRASE}, and in a form field's block`, + ); 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.`; + diagnosticText( + `${description} cannot map a reported failure`, + `note: an 'error when ' line belongs on ${EXTERNAL_HOSTS_PHRASE}`, + 'note: only an external worker reports a failure', + ); export const topicBindingMessage = ( subject: string, item: string, written: readonly string[], ) => - `${subject} carries ${item} only under a 'topic' binding: the engine reads it for a step handed to an external worker` + - (written.length === 0 - ? '.' - : `, and the binding written with ${formatWordList(written)} hands the step to none.`); + diagnosticText( + `${subject} has ${item} but no 'topic' binding`, + `note: the engine reads ${item} only for a step handed to an external worker`, + written.length > 0 && + `note: the binding written with ${formatWordList(written)} hands the step to no worker`, + ); /** `ExternalTaskEntity.evaluateThrowBpmnError` raises a BPMN error and nothing else. */ -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} '.`; +export const MAPPING_HEAD_MESSAGE = diagnosticText( + 'an external task maps a reported failure onto an error only', + `help: write '${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 MAPPING_WHEN_MESSAGE = diagnosticText( + `expected '${ERROR_MAPPING_WHEN}' between the error code and the condition`, + `help: write '${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.`; + diagnosticText( + `setting '${key}' is not an integer or a "\${...}" expression`, + key === USER_TASK_PRIORITY_KEY + ? 'note: the engine parses a constant with Integer.parseInt when the task is created and fails the instance on anything else' + : 'note: 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 VERSION_SHAPE_MESSAGE = diagnosticText( + `setting 'version' is not a positive whole number or a "\${...}" expression`, + 'note: the number may be quoted or not', + 'note: the engine parses the value as an integer when the step runs 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.'; + diagnosticText( + `setting '${key}' is not a period, a date-time, or a "\${...}" expression`, + 'note: the engine parses a constant when the task is created and fails the instance on anything else', + `help: write a period starting with 'P' such as "P3D", or an ISO date-time such as "2026-01-01T09:00:00"`, + ); -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 RETRY_CYCLE_SHAPE_MESSAGE = diagnosticText( + "setting 'retryCycle' is not a duration, a repeat, or a list of durations", + 'note: the engine logs and drops a single interval it cannot read, so the job keeps its default retries', + 'note: a bad member of a list is stored unchecked, and when its turn comes that retry falls back to the default strategy with no wait', + `help: write an ISO 8601 duration such as "PT5M", an 'R/' repeat such as "R3/PT5M", or a comma list such as "PT1M,PT5M"`, +); 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.`; + diagnosticText( + `setting 'resultVariable' has no effect beside '${binding}'`, + "note: the engine hands it to an 'expression' binding only, 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 MAP_DECISION_RESULT_UNREAD_MESSAGE = diagnosticText( + "setting 'mapDecisionResult' has no effect without 'resultVariable'", + 'note: the engine applies the mapping only when it stores the result into that variable', +); 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).`; + diagnosticText( + `setting '${key}' has no 'decision' beside it`, + "note: the engine reads 'binding', 'version', and 'mapDecisionResult' only on a step that evaluates a decision table", + ); -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 HISTORY_TIME_TO_LIVE_MESSAGE = diagnosticText( + "setting 'historyTimeToLive' is not a quoted number of days", + 'note: the engine reads the attribute as text, never as an expression, and refuses to deploy anything else', + `help: write "PD" or ""`, +); -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_LITERAL_MESSAGE = diagnosticText( + "setting 'versionTag' is not a quoted string", + 'note: the engine stores the tag as written and never evaluates it', +); -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.`; +export const VERSION_TAG_LENGTH_MESSAGE = diagnosticText( + `setting 'versionTag' is longer than ${VERSION_TAG_MAX_LENGTH} characters`, + `note: the column the engine stores it in holds ${VERSION_TAG_MAX_LENGTH} characters, so the deployment fails`, +); 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.`; + diagnosticText( + `setting '${key}' holds a "\${...}" template, which the engine never evaluates`, + 'note: the engine stores each entry as a candidate id as written, so the template text itself becomes the id', + ); 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).`; + diagnosticText( + `setting '${key}' on a process header is not a quoted string`, + 'note: the engine stores it as written and never evaluates it', + ); 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.`; + diagnosticText( + `setting '${key}' is not valid on ${description} that does not repeat`, + 'note: it makes one job per run', + `help: write a 'for' clause, or '${ENGINE_KEY_BY_RUN_KEY[key]}' for one job around the step`, + ); -export const RUN_JOB_PRIORITY_MESSAGE = `Setting '${RUN_JOB_PRIORITY_KEY}' does not exist: Operaton reads a job priority off the step alone (BpmnParse.createActivityOnScope), so 'jobPriority' applies to every run's job.`; +const CONDITION_ITEM_MESSAGE = `only '${VALUE_DOCUMENTATION_KEYS.condition}' can follow a condition inside its parentheses`; -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 CONDITION_DOCUMENTATION_KEY_MESSAGE = diagnosticText( + `'${DOCUMENTATION_KEY}' cannot document a condition`, + `help: write '${VALUE_DOCUMENTATION_KEYS.condition}'`, +); + +const COUNT_DOCUMENTATION_MESSAGE = `setting '${VALUE_DOCUMENTATION_KEYS.count}' documents a count, but the 'for' clause has none`; -export const TYPE_VALUE_MESSAGE = `Setting '${TYPE_BINDING_KEY}' must be ${formatWordList(TYPE_BINDING_VALUES)}.`; +const EACH_DOCUMENTATION_MESSAGE = `setting '${VALUE_DOCUMENTATION_KEYS.each}' documents the name after 'each', but the 'for' clause has none`; + +export const RUN_JOB_PRIORITY_MESSAGE = diagnosticText( + `setting '${RUN_JOB_PRIORITY_KEY}' does not exist`, + "note: Operaton reads a job priority off the step only, so 'jobPriority' applies to every run's job", +); + +export const REPEAT_COUNT_MESSAGE = diagnosticText( + 'the repeat count is not a non-negative whole number, a variable, or a "${...}" expression', + 'note: the engine reads a constant as text with Integer.parseInt', + 'note: the engine truncates any other number with intValue()', +); + +export const TYPE_VALUE_MESSAGE = `setting '${TYPE_BINDING_KEY}' must be ${formatWordList(TYPE_BINDING_VALUES)}`; const BUILTIN_BEHAVIOUR_CLASS: Readonly> = { mail: 'MailActivityBehavior', @@ -606,56 +687,83 @@ export const resultVariableBindingMessage = ( 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.`; + diagnosticText( + `${description} cannot carry 'resultVariable' beside '${binding}'`, + `note: the engine refuses to deploy it with "'resultVariableName' not supported for ${element} elements using '${RESULT_VARIABLE_REFUSING_BINDINGS[binding]}'"`, + "help: bind with 'expression' to store the return value, or remove 'resultVariable'", + ); 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]}).`; + diagnosticText( + `${subject} binds ${TYPE_BINDING_KEY}: "${type}" without a ${formatWordList(group.names)} field`, + `note: Operaton refuses to deploy it with "${group.error}"`, + ); 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).`; + diagnosticText( + `field '${name}' is not one a ${type} task takes`, + `note: the engine sets it on ${BUILTIN_BEHAVIOUR_CLASS[type]}, which declares ${formatPlainWordList(BUILTIN_FIELD_NAMES[type], 'and')}`, + ); 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.`; + diagnosticText( + `field '${name}' on a shell task is an expression`, + 'note: Operaton reads every shell field as a fixed value and fails the deployment on an expression', + 'help: write a quoted literal', + ); 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).`; + diagnosticText( + `field '${name}' on a shell task is not "true" or "false"`, + 'note: the engine reads any other spelling as false', + ); const SHELL_FLAG_LITERAL_SET: ReadonlySet = new Set( SHELL_FLAG_LITERALS, ); 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.'; + diagnosticText( + `${subject} runs a fenced script, which the engine hands no field list`, + `help: remove the script and bind the listener with ${formatWordList(LISTENER_FIELD_BINDING_KEYS)} to inject one`, + ); export const fieldValueMessage = (name: string) => - `Field '${name}' takes a quoted string or a "\${...}" expression; ` + - 'put the value in quotes.'; + diagnosticText( + `field '${name}' is not a quoted string or a "\${...}" expression`, + 'help: put the value in quotes', + ); /** * 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.`; + diagnosticText( + `field '${name}' holds quoted text opening with '\${' or '#{'`, + 'note: that opening picks the expression slot, so the engine evaluates the text instead of injecting it as written', + 'help: 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.`; + diagnosticText( + `field '${name}' is empty`, + 'note: the engine reads an empty value as absent, so the field declares neither a fixed value nor an expression and the deployment fails', + `help: 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.'; +export const EMPTY_STRING_VALUE_MESSAGE = diagnosticText( + 'an empty or blank string writes no value', + 'note: the engine trims it and reads it as absent, not as an empty string', + `help: write '\${""}' for an actual empty string`, +); const JUEL_RESERVED_WORD_SET: ReadonlySet = new Set( JUEL_RESERVED_WORDS, @@ -667,11 +775,18 @@ const JUEL_RAW_TEMPLATE_WORD_SET: ReadonlySet = new Set([ ]); 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}}".`; + diagnosticText( + `'${word}' is a JUEL keyword, so the engine refuses any expression naming it`, + `help: reach the variable through a string key: "\${${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}}".`; + diagnosticText( + `'${name}' contains a hyphen, which JUEL reads as a minus`, + 'note: the engine evaluates a subtraction instead of reading the variable', + `help: reach the variable through a string key: "\${${key}}"`, + ); type ConditionSlot = 'condition' | 'until'; @@ -680,11 +795,27 @@ export const nonBooleanConditionMessage = ( 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).`; + ? diagnosticText( + `the 'until' condition must be boolean, but it is ${shape}`, + 'note: the engine throws when a completion condition evaluates to anything else', + ) + : diagnosticText( + `the condition must be boolean, but it is ${shape}`, + `note: the engine throws 'condition expression returns ${shape === 'null' ? 'null' : 'non-Boolean'}' when it evaluates it`, + ); -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."; +export const COMPOSITE_OPERAND_MESSAGE = diagnosticText( + 'a composite template cannot be an operand of the surrounding expression', + "note: JUEL has no '${' token inside an expression", + "note: only a raw that is exactly one '${...}' with no '}' outside a string literal in its body can be an operand", + 'help: write the whole expression as one raw template', +); + +export const NESTED_TEMPLATE_MESSAGE = diagnosticText( + "a '${...}' template holds another '${' inside it", + "note: JUEL has no '${' token inside an expression, so the engine cannot parse it", + 'help: close the template before any text around it, as in "1${x}"', +); const EL_BINDING_KEYS: ReadonlySet = new Set([ 'expression', @@ -709,142 +840,218 @@ const EMPTY_BINDING_NOUN: Readonly> = { }; 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}: '.`; + diagnosticText( + `setting '${key}' is quoted text, not a "\${...}" template or a bare name`, + key === 'expression' + ? 'note: the string evaluates to itself and runs nothing' + : 'note: a string resolves to no delegate to run', + `help: 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.`; + diagnosticText( + `setting '${key}' holds a "\${...}" template, but the engine loads a class name as written`, + 'note: the template is never evaluated', + `help: 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. + * deployment; the other keys deploy blank and fail at the step. Quoted text is + * a valid JUEL expression evaluating to itself, so on `expression` it deploys + * and runs as a no-op, while a delegate resolving to a string fails the step. */ -function bindingValueMessage(key: string, value: Expr): string | undefined { +function bindingValueMessage( + key: string, + value: Expr, +): ['error' | 'warning', 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 [ + 'error', + diagnosticText(`setting '${key}' is empty`, `help: name the ${noun}`), + ]; } - return EL_BINDING_KEYS.has(key) ? literalElBindingMessage(key) : undefined; + if (!EL_BINDING_KEYS.has(key)) return undefined; + return [ + key === 'expression' ? 'warning' : 'error', + literalElBindingMessage(key), + ]; } const alternative = CLASS_BINDING_ALTERNATIVE[key]; return isRawExpr(value) && alternative !== undefined - ? templateAsClassMessage(key, alternative) + ? ['error', templateAsClassMessage(key, alternative)] : undefined; } export const unknownDirectionMessage = ( word: string, legal: readonly string[], -) => `Unknown parameter direction '${word}'; write ${formatWordList(legal)}.`; +) => + diagnosticText( + `unknown parameter direction '${word}'`, + `help: 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.'; +export const FORM_KEY_AND_REF_MESSAGE = diagnosticText( + "a user task names its form with both 'formKey' and 'formRef'", + 'note: the engine refuses to deploy a task carrying the two', +); const BINDING_MODE_PHRASES: readonly string[] = CALL_BINDING_VALUES.map( (value) => `'binding: ${value}'`, ); -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.`; +export const FORM_REF_BINDING_MESSAGE = diagnosticText( + "'formRef' has no binding resolving it", + 'note: the engine refuses to deploy a form reference with none', + `help: add ${formatPlainWordList([...BINDING_MODE_PHRASES, "'version: '"])}`, +); -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'."; +export const FORM_REF_MISSING_MESSAGE = diagnosticText( + "'binding' and 'version' have no 'formRef' to pin", + 'note: they pin which deployed version of a form the engine resolves', +); export const formFieldSettingsOnlyMessage = (id: string, text: string) => - `Form field '${id}' takes 'key: value' settings in its parens; '${text}' is not one.`; + `'${text}' in form field '${id}' is not a 'key: value' setting`; export const unknownFormFieldSettingMessage = (id: string, key: string) => - `Unknown form field setting '${key}' on '${id}'; write ${formatWordList(FORM_FIELD_SETTING_KEYS)}.`; + diagnosticText( + `unknown form field setting '${key}' on '${id}'`, + `help: write ${formatWordList(FORM_FIELD_SETTING_KEYS)}`, + ); 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.`; + diagnosticText( + `constraint '${name}' does not fit the ${field.type} field '${field.id}'`, + `note: it fits a ${formatPlainWordList(fits)} field`, + `note: the engine checks a submitted ${formatPlainWordList(fits)} only and fails every other submission`, + ); 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.`; + diagnosticText( + `setting 'pattern' does not fit the ${field.type} field '${field.id}'`, + "note: 'pattern' is the date pattern a 'date' field is parsed with", + `note: the engine reads no pattern off a ${field.type} field`, + ); export const flagFalseMessage = (key: string) => - `A field is ${key} only while the setting is written, so '${key}: false' says nothing; leave the setting out.`; + diagnosticText( + `'${key}: false' says nothing`, + `note: a field is ${key} only while the setting is written`, + 'help: leave the setting out', + ); export const booleanShapeMessage = (key: string) => - `Setting '${key}' takes an unquoted boolean; write '${key}: true' or '${key}: false'.`; + diagnosticText( + `setting '${key}' is not an unquoted boolean`, + `help: write '${key}: true' or '${key}: false'`, + ); export const flagNotTrueMessage = (key: string) => - `Setting '${key}' takes the literal true; write '${key}: true'.`; + diagnosticText( + `setting '${key}' is not the literal true`, + `help: write '${key}: true'`, + ); export const integerBoundMessage = (key: string) => - `Setting '${key}' takes an integer literal or a quoted integer such as "-5".`; + diagnosticText( + `setting '${key}' is not an integer`, + 'help: write an integer literal or a quoted integer such as "-5"', + ); -export const PATTERN_VALUE_MESSAGE = `Setting 'pattern' takes a non-empty quoted date pattern such as "dd/MM/yyyy".`; +export const PATTERN_VALUE_MESSAGE = diagnosticText( + "setting 'pattern' is not a non-empty quoted date pattern", + 'help: write a pattern such as "dd/MM/yyyy"', +); -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 PATTERN_LETTERS_MESSAGE = diagnosticText( + "setting 'pattern' holds a letter SimpleDateFormat does not read", + 'note: a pattern holds only the letters G, y, Y, M, L, w, W, D, d, F, E, u, a, H, k, K, h, m, s, S, z, Z, and X, quoted literal runs, and non-letters', + 'note: the engine builds a java.text.SimpleDateFormat from it and throws on any other letter', +); -export const VALIDATOR_EMPTY_MESSAGE = - "Setting 'validator' cannot be empty; name the class or the expression it " + - 'resolves (FormValidators.createValidator).'; +export const VALIDATOR_EMPTY_MESSAGE = diagnosticText( + "setting 'validator' is empty", + 'help: name the class or the expression it resolves', +); export const valuesOnNonEnumMessage = (field: FormField) => - `Value lines belong on an 'enum' field; '${field.id}' is a ${field.type} field.`; + diagnosticText( + `the ${field.type} field '${field.id}' cannot take value lines`, + "note: value lines belong on an 'enum' field", + ); 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"'.`; + diagnosticText( + `enum field '${id}' offers no values`, + 'note: the engine rejects every submitted value', + `help: add a value line such as 'basic "Basic"'`, + ); -export const duplicateValueMessage = (id: string) => `Duplicate value '${id}'.`; +export const duplicateValueMessage = (id: string) => `duplicate value '${id}'`; -/** 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)}.`; + diagnosticText( + `the default "${value}" of enum field '${id}' names none of its values`, + 'note: the engine converts the default every time the form renders, so the form never opens', + `help: write ${formatWordList(ids)}`, + ); 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.'; + diagnosticText( + `the default ${text} of number field '${id}' is not an integer`, + 'note: the engine converts it with Long.valueOf every time the form renders 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).'; + diagnosticText( + `the default ${text} of boolean field '${id}' is not "true" or "false"`, + 'note: the engine reads any other spelling as false', + ); /** 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.'; + diagnosticText( + `the default "${value}" of date field '${id}' is an ISO date, which the engine's default pattern does not read`, + 'note: the default pattern is "dd/MM/yyyy"', + "help: add a 'pattern' setting, or write the date to fit the default pattern", + ); 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' : ''}.`; + diagnosticText( + `unknown member direction '${direction}' in form field '${id}'`, + `note: its block takes 'property = ""' lines${isEnum ? ' and value lines' : ''}`, + ); export const propertyValueMessage = (name: string) => - `Property '${name}' takes a quoted string or a "\${...}" expression; ` + - 'put the value in quotes.'; + diagnosticText( + `property '${name}' is not a quoted string or a "\${...}" expression`, + 'help: put the value in quotes', + ); const isQuotedMatching = (value: Expr | undefined, shape: RegExp): boolean => isLiteralString(value) && shape.test(value.value); +/** An integer literal, or quoted text holding one. */ const isIntegerValue = (value: Expr | undefined): boolean => - value !== undefined && integerLiteralText(value) !== undefined; + (value !== undefined && integerLiteralText(value) !== undefined) || + isQuotedMatching(value, FORM_BOUND_TEXT); type ValueShapeRule = ( key: string, @@ -863,9 +1070,7 @@ 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); + isIntegerValue(value) ? undefined : integerBoundMessage(key); /** A blank literal is the one shape `FormValidators.createValidator` refuses to deploy. */ const nonEmptyText: ValueShapeRule = (_key, value) => @@ -931,48 +1136,51 @@ const PROCESS_HEADER_VALUE_RULES: Readonly> = { }; function particleOnlyMessage(subject: string): string { - return `Only ${subject} takes a particle.`; + return `only ${subject} takes a particle`; } function nameRequiredMessage(subject: string, kind: string): string { - return `${subject} needs the ${kind}'s name: the engine matches ${kind}s by name.`; + return diagnosticText( + `${subject} has no ${kind} name`, + `note: the engine matches ${kind}s by name`, + ); } -const TIMER_PAYLOAD_PREFIX = - 'A timer needs to know how to read the time: write '; +const TIMER_PAYLOAD_HEADLINE = 'the timer does not say when it fires'; -export const TIMER_PAYLOAD_MESSAGE = - TIMER_PAYLOAD_PREFIX + - `'timer("PT1H")', 'timer(at: "2026-08-01T09:00:00")', or ` + - `'timer(every: "R/PT10M")'.`; +export const TIMER_PAYLOAD_MESSAGE = diagnosticText( + TIMER_PAYLOAD_HEADLINE, + `help: write 'timer("PT1H")', 'timer(at: "2026-08-01T09:00:00")', or 'timer(every: "R/PT10M")'`, +); -export const LISTENER_TIMER_PAYLOAD_MESSAGE = - TIMER_PAYLOAD_PREFIX + - `'after "PT1H"', 'at "2026-08-01T09:00:00"', or 'every "R/PT10M"'.`; +export const LISTENER_TIMER_PAYLOAD_MESSAGE = diagnosticText( + TIMER_PAYLOAD_HEADLINE, + `help: write 'after "PT1H"', 'at "2026-08-01T09:00:00"', or 'every "R/PT10M"'`, +); -const timerShapeMessage = ( - particle: string, - expected: string, - calendar: string, -) => - `'${particle}' takes ${expected}; the engine reads it as text and fails when it does not fit (${calendar}).`; +const timerShapeMessage = (particle: string, noun: string, help: string) => + diagnosticText( + `this '${particle}' time is not ${noun}`, + 'note: the engine reads it as text and fails when it does not fit', + `help: write ${help}`, + ); 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', + 'an ISO 8601 duration', + 'a duration such as "PT1H", on its own or beside a start or end date-time such as "2026-01-01T00:00:00/PT1H" or "PT1H/2026-12-31T00:00:00"', ); 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', + 'an ISO date-time or a duration', + 'a date-time such as "2026-08-01T09:00:00", or a duration counted from now such as "P1D"', ); 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', + 'an ISO 8601 repeat or a cron expression', + 'a repeat such as "R/PT10M", or a six-field cron expression or one of its nicknames such as "@daily"', ); const TIMER_SHAPE_BY_PARTICLE: Readonly< @@ -984,242 +1192,366 @@ const TIMER_SHAPE_BY_PARTICLE: Readonly< }; /** `only` omits the name slot, which an author who wrote none should not see as a placeholder. */ -const CONDITION_PHRASING = { +const PAYLOAD_PHRASING = { handler: { - subject: 'A condition handler', + subject: 'a condition handler', clause: 'on condition', only: "'on condition'", + timer: "'on timer'", }, catch: { - subject: 'An awaited condition', + subject: 'an awaited condition', clause: 'await condition', only: "'await condition'", + timer: "'await timer'", }, start: { - subject: 'A condition start', + subject: 'a condition start', clause: 'start S condition', only: 'a condition start', + timer: 'a timer start', }, } as const; -type ConditionPosition = keyof typeof CONDITION_PHRASING; +type PayloadPosition = keyof typeof PAYLOAD_PHRASING; + +export const SECOND_PAREN_VALUE_MESSAGE = diagnosticText( + 'a second unkeyed value in the parens names nothing', + 'note: the parens carry one unkeyed value, the payload, and a second one never reaches the engine', + "help: write it as a 'key: value' setting, or remove it", +); + +export const COMPENSATE_TYPO_MESSAGE = diagnosticText( + "unknown event kind 'compensate'", + "help: write 'compensation'", +); + +export const CONDITIONAL_TYPO_MESSAGE = diagnosticText( + "unknown event kind 'conditional'", + "help: write 'condition'", +); + +export const PARALLEL_SECOND_ELSE_MESSAGE = diagnosticText( + "a 'parallel' statement has a second 'else' branch", + "note: the first 'else' already runs when no condition held", + 'help: fold this branch into it, or give it a condition', +); + +export const PARALLEL_ELSE_WITHOUT_CONDITION_MESSAGE = diagnosticText( + "this 'else' branch has no sibling with a condition to fall back from", + 'note: with no condition anywhere, every branch runs', + "help: give a sibling a condition, or remove the 'else'", +); + +export const PARALLEL_ELSE_BESIDE_UNCONDITIONED_MESSAGE = diagnosticText( + "this 'else' branch can never run", + "note: an 'else' runs only when no sibling branch was taken, and a branch with no condition is always taken", + "help: give every sibling a condition, or remove the 'else'", +); -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.'; +export const elseFailWordMessage = (word: string): string => + diagnosticText( + `unknown word '${word}' after 'else'`, + `note: 'else' takes a block, or '${ELSE_FAIL_WORD}' to stop the run with an error when no condition holds`, + ); -export const COMPENSATE_TYPO_MESSAGE = - "Unknown event kind 'compensate'; write 'compensation'."; +export const ELSE_FAIL_WITHOUT_CONDITION_MESSAGE = diagnosticText( + `'else ${ELSE_FAIL_WORD}' has no branch with a condition to fail from`, + 'note: with no condition anywhere, every branch runs', + `help: give a branch a condition, or remove 'else ${ELSE_FAIL_WORD}'`, +); -export const CONDITIONAL_TYPO_MESSAGE = `Unknown event kind 'conditional'; did you mean 'condition'?`; +export const ELSE_FAIL_BESIDE_UNCONDITIONED_MESSAGE = diagnosticText( + `'else ${ELSE_FAIL_WORD}' can never happen`, + 'note: a branch with no condition is always taken, so the run never finds no branch to take', + `help: give every branch a condition, or remove 'else ${ELSE_FAIL_WORD}'`, +); -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.'; +export const ELSE_FAIL_BESIDE_ELSE_MESSAGE = diagnosticText( + `'else ${ELSE_FAIL_WORD}' can never happen beside an 'else' branch`, + "note: the 'else' branch runs whenever no condition holds", + 'help: remove one of the two', +); -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'."; +export const START_TRIGGER_IN_HANDLER_MESSAGE = diagnosticText( + 'the start of an event-handler body cannot carry a trigger', + "note: the handler's own 'on ' is what it catches", +); -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'."; +export const NESTED_START_FORM_MESSAGE = diagnosticText( + 'a start inside a subprocess, attempt block, or handler body cannot take a form', + "note: the engine reads a start form off the process's own start only, so this form is never shown", +); -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_INITIATOR_MESSAGE = diagnosticText( + "a start inside a subprocess, attempt block, or handler body cannot take 'initiator'", + "note: the engine reads it off the process's own start only, so nothing is written", +); -export const NESTED_START_FORM_MESSAGE = - 'A start inside a subprocess, attempt block, or handler body takes no ' + - "form: the engine reads a start form off the process's own start alone " + - '(BpmnParse.parseStartFormHandlers) and BpmnParse.parseScopeStartEvent ' + - 'reads none, so this form is never shown.'; +const SECOND_DEFAULT_START_HEADLINE = + "this is the process's second plain or timer start"; -export const NESTED_START_INITIATOR_MESSAGE = - 'A start inside a subprocess, attempt block, or handler body takes no ' + - "'initiator': the engine reads it off the process's own start alone " + - '(BpmnParse.parseProcessDefinitionStartEvent) and ' + - 'BpmnParse.parseScopeStartEvent reads none, so nothing is written.'; +const SECOND_DEFAULT_START_REFUSAL = + 'note: Operaton refuses a second one, so the deployment fails'; -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 SECOND_DEFAULT_START_MESSAGE = diagnosticText( + SECOND_DEFAULT_START_HEADLINE, + SECOND_DEFAULT_START_REFUSAL, + 'help: 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 const START_AFTER_IMPLICIT_START_MESSAGE = diagnosticText( + SECOND_DEFAULT_START_HEADLINE, + 'note: a body that does not open with a start gets a plain start of its own', + SECOND_DEFAULT_START_REFUSAL, + 'help: 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.`; + return diagnosticText( + `another start already subscribes to ${trigger} '${name}'`, + `note: Operaton keeps one ${trigger} start subscription per name and process, 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.`; + return diagnosticText( + `another start already carries the condition '${text}'`, + 'note: Operaton keeps one conditional start per condition text and process, 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.`; + return diagnosticText( + `setting '${key}' is already written on the 'on timer' head`, + "note: both land on this start event, the element the engine reads the timer job's settings from", + 'help: 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.`; + return diagnosticText( + `another branch of this 'await' already catches ${trigger} '${name}'`, + `note: every branch subscribes on the gateway's own scope, which keeps one ${trigger} subscription per name, 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 const DEAD_LOOP_MESSAGE = diagnosticText( + 'this loop can never repeat', + "note: every path through the 'do' body ends or redirects the flow, so the condition is never evaluated", + 'note: the loop gateway would have no incoming flow, which is invalid BPMN', + 'help: end after the loop, or keep one path through the body', +); + +const UNREACHABLE_MESSAGE = diagnosticText( + 'this step can never run', + "note: an earlier 'end', 'throw', 'goto', 'emit link', or an 'if', 'parallel', or 'await' whose every branch ends always ends or redirects the flow before it", + 'note: the step would have no incoming flow, which is invalid BPMN', +); 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.`; + return diagnosticText( + `the '${keyword}' body has no steps`, + 'note: with nothing to loop over, the loop and its condition are dropped', + 'help: 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.`; + return diagnosticText( + `${what} written inside a body belongs to nothing and is dropped`, + `help: 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.`; - -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; - -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 ` + - `steps run, or 'on : condition(amount > 100)' to watch only ` + - 'while that step runs. ' + - END_TRIGGERS_MESSAGE; +const END_TRIGGERS_NOTE = + "note: 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 endWaitMessage = (kind: string, payload: string) => + diagnosticText( + `a ${kind} cannot end a process`, + `note: a ${kind} is something a process waits on`, + END_TRIGGERS_NOTE, + `help: write 'await ${kind}${payload}' to pause the flow here`, + `help: write 'on ${kind}${payload}' to react while the surrounding steps run`, + `help: write 'on : ${kind}${payload}' to watch only while that step runs`, + ); + +export const END_TIMER_MESSAGE = endWaitMessage('timer', '("PT1H")'); + +export const END_CONDITION_MESSAGE = endWaitMessage( + 'condition', + '(amount > 100)', +); export const END_TRIGGER_NO_CODE_MESSAGES: Readonly> = { - terminate: - 'Terminate names nothing: it stops every running path in this scope; ' + - 'leave the payload out.', - cancel: - 'Cancel names nothing: it gives up the block this end sits in; leave the ' + - 'payload out.', + terminate: diagnosticText( + "'terminate' takes no payload", + 'note: it stops every running path in this scope and names nothing', + 'help: remove the payload', + ), + cancel: diagnosticText( + "'cancel' takes no payload", + 'note: it gives up the block this end sits in and names nothing', + 'help: remove the payload', + ), } satisfies Record<(typeof END_TRIGGERS)[number], string>; -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'.`; +export const CANCEL_END_PLACEMENT_MESSAGE = diagnosticText( + "this cancel end is not inside an 'attempt' block", + 'note: a cancel end gives up the block it sits in, and the engine refuses one anywhere else', + "help: wrap the steps to give up in 'attempt { ... }', or end this path with a plain 'end'", +); -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.'; +export const CANCEL_HOSTLESS_MESSAGE = diagnosticText( + 'this cancel handler names no block', + 'note: a cancel is caught on the block it gives up', + 'note: a handler with no host opens on its own trigger, and nothing opens on a cancel', + "help: write 'on : cancel'", +); -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'."; +export const CANCEL_ALONGSIDE_MESSAGE = diagnosticText( + "a cancel handler cannot run 'alongside'", + 'note: giving a block up ends every step still running inside it, so nothing is left to run alongside', + "help: remove 'alongside'", +); -export const EMPTY_CODE_MESSAGE = - 'An empty code ("") is not a catch-all; to catch every error, leave the ' + - 'payload out entirely.'; +export const EMPTY_CODE_MESSAGE = diagnosticText( + 'an empty code ("") is not a catch-all', + 'help: to catch every error, leave the payload out', +); -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.'; +export const CANCEL_NO_CODE_MESSAGE = diagnosticText( + 'a cancel handler catches nothing by name', + 'note: it runs when its block is given up', + 'help: leave the payload out', +); -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.`; +const CANCEL_IS_GIVING_UP_NOTE = + 'note: a cancel is how a block gives itself up'; -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 CANCEL_END_HELP = + "help: write 'end cancel' inside the 'attempt' block"; -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.'; +export const CANCEL_NOT_RAISED_MESSAGE = diagnosticText( + 'a cancel cannot be raised', + CANCEL_IS_GIVING_UP_NOTE, + CANCEL_END_HELP, +); -export const COMPENSATION_BINDINGS_MESSAGE = - "'(code: c)' bindings belong to error and escalation handlers; compensation carries no values."; +export const CANCEL_NOT_AWAITED_MESSAGE = diagnosticText( + 'a cancel cannot be awaited', + CANCEL_IS_GIVING_UP_NOTE, + CANCEL_END_HELP, + "help: write 'on : cancel' beside the block to say what happens then", +); -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'."; +export const COMPENSATION_NO_CODE_MESSAGE = diagnosticText( + 'compensation takes no code or name', + "note: 'on compensation { }' is the undo block of the subprocess or attempt block it sits in", + 'help: leave the payload out', +); -export const COMPENSATION_PLACEMENT_MESSAGE = - "An undo block belongs directly inside the 'subprocess' or 'attempt' whose " + - 'work it undoes: a process cannot undo itself.'; +export const COMPENSATION_BINDINGS_MESSAGE = diagnosticText( + 'compensation carries no values to bind', + "note: '(code: c)' bindings belong to error and escalation handlers", +); -export const COMPENSATION_DUPLICATE_MESSAGE = - 'A subprocess or an attempt block has one undo block; merge the steps.'; +export const COMPENSATION_ALONGSIDE_MESSAGE = diagnosticText( + "an undo block cannot run 'alongside'", + 'note: the work it reverses has already finished, so no flow is running alongside', + "help: remove 'alongside'", +); -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.'; +export const COMPENSATION_PLACEMENT_MESSAGE = diagnosticText( + "this undo block is not directly inside a 'subprocess' or 'attempt'", + "note: an undo block belongs directly inside the 'subprocess' or 'attempt' whose work it undoes", + 'note: a process cannot undo itself', +); + +export const COMPENSATION_DUPLICATE_MESSAGE = diagnosticText( + 'this subprocess or attempt block already has an undo block', + 'help: merge the steps into one undo block', +); -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."; +export const COMPENSATION_HOST_MESSAGE = diagnosticText( + 'compensation cannot attach to a host', + "note: it undoes a subprocess's completed work through its own undo block, not through a boundary event", + "help: remove the host and write 'on compensation { ... }' directly inside the subprocess or attempt block it reverses", +); -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.'; +export const LINK_CATCH_FLOW_MESSAGE = diagnosticText( + "the flow runs into an 'await link'", + "note: a link catch is entered only by 'emit link' of the same name", + "help: end the path before it with 'end', 'throw', 'goto', or 'emit link'", +); + +export const LINK_IN_RACE_MESSAGE = diagnosticText( + "'link' cannot head a branch of an 'await' block", + 'note: the engine refuses a link catch after an event-based gateway', + `help: write 'await link("")' as its own statement`, +); export const ESCALATION_NO_MESSAGE_MESSAGE = - 'An escalation carries a code but no message.'; + 'an escalation carries a code but no message'; -export const DECLARATION_SETTINGS_ONLY_MESSAGE = - `A declaration's parens take only ${formatWordList(EVENT_BINDING_FIELDS)} ` + - "settings, written 'key: value'."; +export const DECLARATION_SETTINGS_ONLY_MESSAGE = diagnosticText( + "this value in a declaration's parens is not a setting", + `note: a declaration's parens take only ${formatWordList(EVENT_BINDING_FIELDS)} settings, written 'key: value'`, +); export const settingsOnlyMessage = (description: string) => - `The parens of ${description} take only settings, written 'key: value'.`; + diagnosticText( + `this value in the parens of ${description} is not a setting`, + `note: the parens of ${description} take only settings, written 'key: value'`, + ); 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]}'.`; + diagnosticText( + `setting '${key}' is not valid on ${description}`, + 'note: a loop has one gateway', + `help: write '${ENGINE_KEY_BY_JOIN_KEY[key]}'`, + ); 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.'; + diagnosticText( + `setting '${key}' is not valid on ${description}`, + 'note: Operaton refuses it on an event-based gateway', + 'help: write it on the branch triggers', + ); 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.`; + diagnosticText( + `setting '${key}' has no effect`, + `note: every branch of this ${description.replace(/^an? /, '')} ends its path, so there is no join for it to set`, + ); 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).'; + diagnosticText( + `setting '${key}' on ${description} configures no job`, + `note: Operaton creates one only when ${formatWordList(pairing)} is also set`, + ); 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.`; + diagnosticText( + `setting '${key}' on ${description} configures no per-run job`, + `note: Operaton creates the per-run job in the multi-instance body only when ${formatWordList(pairing)} is also set`, + ); /** * A timer creates its own job regardless of the async flags: a node with its @@ -1312,21 +1644,37 @@ const BOOLEAN_OK: ReadonlySet = new Set([ 'unknown', ]); -function collectNamedStatements(process: Process): NamedStatement[] { - return AstUtils.streamAst(process).filter(isNamedStatement).toArray(); +function statementName(stmt: Statement): string | undefined { + return isNamedStatement(stmt) ? stmt.name : undefined; } -function collectGotoTargetNames(process: Process): Set { - return new Set( - AstUtils.streamAst(process) - .filter(isGotoStatement) - .map((goto) => goto.target?.$refText ?? '') - .filter((name) => name.length > 0), - ); +/** + * Names a `goto` can enter `stmt` by. A loop's back-edge leads every step of + * its body to the loop's gateway, so a `goto` to any of them enters the whole + * loop. + */ +function entryNames(stmt: Statement): string[] { + return [ + ...ownName(stmt), + ...(isWhileStatement(stmt) || isDoWhileStatement(stmt) + ? flowNames(stmt.body) + : []), + ]; } -function statementName(stmt: Statement): string | undefined { - return isNamedStatement(stmt) ? stmt.name : undefined; +/** Every step a block's own flow runs through, nested blocks included. */ +function flowNames(block: Block | undefined): string[] { + return blockStatements(block) + .filter((stmt) => !isOnHandler(stmt)) + .flatMap((stmt) => [ + ...ownName(stmt), + ...childBlocks(stmt).flatMap(flowNames), + ]); +} + +function ownName(stmt: Statement): string[] { + const name = statementName(stmt); + return name === undefined ? [] : [name]; } /** @@ -1408,31 +1756,37 @@ function statementTerminates(stmt: Statement): boolean { } // With every parallel branch conditioned, the transform's fallback runs straight to the join. const counts = isIfStatement(stmt) - ? stmt.elseBlock !== undefined + ? stmt.elseBlock !== undefined || stmt.fail !== undefined : isDoWhileStatement(stmt) || isRaceStatement(stmt) || (isParallelStatement(stmt) && - stmt.branches.some((branch) => branch.condition === undefined)); + (stmt.fail !== undefined || + stmt.branches.some((branch) => branch.condition === undefined))); return ( counts && childBlocks(stmt).every((block) => blockTerminates(blockStatements(block))) ); } -function hasConditionedBranch(stmt: ParallelStatement): boolean { - return stmt.branches.some((branch) => branch.condition !== undefined); -} - -function hasUnconditionedBranch(stmt: ParallelStatement): boolean { - return stmt.branches.some( - (branch) => branch.condition === undefined && !branch.otherwise, - ); +function checkElseFailWord( + stmt: IfStatement | ParallelStatement, + accept: ValidationAcceptor, +): void { + if (stmt.fail === undefined || stmt.fail === ELSE_FAIL_WORD) return; + accept('error', elseFailWordMessage(stmt.fail), { + node: stmt, + property: 'fail', + }); } +/** + * The transform takes a block's exit from its last flow statement, so a + * terminator followed by a re-entry (a link catch, a `goto` target) leaves the + * block open. + */ function blockTerminates(statements: Statement[]): boolean { - return statements.some( - (stmt) => !isOnHandler(stmt) && statementTerminates(stmt), - ); + const last = statements.findLast((stmt) => !isOnHandler(stmt)); + return last !== undefined && statementTerminates(last); } function previousFlowStatement( @@ -1512,8 +1866,10 @@ export class BpmnScriptValidator { (extra) => accept( 'error', - 'Only one process is supported per file. ' + - 'Move additional processes into separate files.', + diagnosticText( + 'only one process is supported per file', + 'help: move additional processes into separate files', + ), { node: extra, property: 'name' }, ), ); @@ -1526,9 +1882,10 @@ export class BpmnScriptValidator { */ private checkStartPosition( process: Process, + nodes: readonly AstNode[], accept: ValidationAcceptor, ): void { - for (const node of AstUtils.streamAst(process)) { + for (const node of nodes) { if (!isStartEvent(node) || node.name === undefined) continue; if (node.$container === process) { const prev = previousFlowStatement( @@ -1544,10 +1901,11 @@ export class BpmnScriptValidator { } accept( 'error', - `'start ${node.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.', + diagnosticText( + `the statement before 'start ${node.name}' still flows on to it`, + 'note: a start opens an entry of its own and takes no incoming flow', + "help: close that flow first with 'end', 'throw', or 'goto', or move the start ahead of that step", + ), { node, property: 'name' }, ); continue; @@ -1566,8 +1924,11 @@ export class BpmnScriptValidator { } accept( 'error', - `'start ${node.name}' must be a top-level statement of its process, or the first statement of its subprocess, attempt block, or event-handler body. ` + - 'A start event cannot have incoming flows.', + diagnosticText( + `'start ${node.name}' is neither a top-level statement nor the first statement of its block`, + 'note: a start event cannot have incoming flows', + 'note: a start belongs at the top level of its process, or first in its subprocess, attempt block, or event-handler body', + ), { node, property: 'name' }, ); } @@ -1647,31 +2008,36 @@ export class BpmnScriptValidator { if (process.name !== undefined && hasNoFlowStep(process.body)) { accept( 'error', - `Process '${process.name}' has no flow steps: a process needs at least one step on its main flow (handlers alone do not start a process).`, + diagnosticText( + `process '${process.name}' has no flow steps`, + 'note: a process needs at least one step on its main flow, and handlers alone do not start it', + ), { node: process, property: 'name' }, ); } const symbols = this.variables.collect(process); + const nodes = AstUtils.streamAst(process).toArray(); this.checkKeywordVarDecls(process, accept); - for (const expr of collectExpressions(process)) { + for (const expr of nodes.filter(isExpr)) { this.checkExpression(expr, symbols, accept); } - this.checkStartPosition(process, accept); + this.checkStartPosition(process, nodes, accept); this.checkDefaultStart(process, accept); - const named = collectNamedStatements(process); + const named = nodes.filter(isNamedStatement); this.checkReservedNames(named, accept); + checkEngineIds([process, ...named], accept); this.checkDuplicateVarDecls(process, accept); this.checkProcessAttributes(process, accept); this.checkDuplicateStatementNames(process, named, accept); - this.checkFormVariableAgreement(process, accept); - this.checkUnreachableStatements(process, accept); - this.checkLinkEvents(process, accept); - this.checkHandlerDuplicates(process, accept); + this.checkFormVariableAgreement(process, nodes, accept); + this.checkUnreachableStatements(process, nodes, accept); + this.checkLinkEvents(nodes, accept); + this.checkHandlerDuplicates(nodes, accept); this.checkCodeDecls(process, accept); }; @@ -1683,9 +2049,15 @@ export class BpmnScriptValidator { */ private checkUnreachableStatements( process: Process, + nodes: readonly AstNode[], accept: ValidationAcceptor, ): void { - const gotoTargets = collectGotoTargetNames(process); + const gotoTargets = new Set( + nodes + .filter(isGotoStatement) + .map((goto) => goto.target?.$refText ?? '') + .filter((name) => name.length > 0), + ); const scan = (statements: Statement[]): void => { let reachable = true; @@ -1702,24 +2074,15 @@ export class BpmnScriptValidator { property: 'trigger', }); } - const name = statementName(stmt); if ( isStartEvent(stmt) || isLinkCatch(stmt) || - (name !== undefined && gotoTargets.has(name)) + entryNames(stmt).some((name) => gotoTargets.has(name)) ) { reachable = true; } if (!reachable) { - accept( - 'error', - 'This step can never run: an earlier `end`, `throw`, `goto`, ' + - '`emit link`, or an all-terminating `if`/`parallel`/`await` in ' + - 'the same block always ends or redirects the flow before ' + - 'reaching it, so this step would lower to a disconnected node ' + - 'with no incoming flow, which is invalid BPMN.', - { node: stmt }, - ); + accept('error', UNREACHABLE_MESSAGE, { node: stmt }); } else { for (const block of childBlocks(stmt)) { scan(blockStatements(block)); @@ -1740,10 +2103,13 @@ export class BpmnScriptValidator { * (`ScopeImpl.findActivityAtLevelOfSubprocess`); a catch with no throw * deploys, so it only warns. */ - private checkLinkEvents(process: Process, accept: ValidationAcceptor): void { + private checkLinkEvents( + nodes: readonly AstNode[], + accept: ValidationAcceptor, + ): void { const throws: EmitStatement[] = []; const catches: IntermediateCatchEvent[] = []; - for (const node of AstUtils.streamAst(process)) { + for (const node of nodes) { if (isLinkThrow(node) && payloadTextOf(node.items)) throws.push(node); if (isLinkCatch(node) && payloadTextOf(node.items)) catches.push(node); } @@ -1757,9 +2123,10 @@ export class BpmnScriptValidator { forEachDuplicate(catches, nameOf, (extra) => accept( 'error', - `Another 'await link("${nameOf(extra)}")' already catches this link: ` + - 'the engine keeps one catch per link name in the whole file, even ' + - 'across subprocesses.', + diagnosticText( + `another 'await link("${nameOf(extra)}")' already catches this link`, + 'note: the engine keeps one catch per link name in the whole file, even across subprocesses', + ), at(extra), ), ); @@ -1773,9 +2140,11 @@ export class BpmnScriptValidator { if (catchEvent === undefined) { accept( 'error', - `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 flow should continue.', + diagnosticText( + `no 'await link("${name}")' catches this link`, + 'note: the engine refuses to deploy an emitted link with no catch of its name', + 'help: write one where the flow should continue', + ), at(throwEvent), ); continue; @@ -1783,9 +2152,11 @@ export class BpmnScriptValidator { if (enclosingFlowContainer(catchEvent) !== container) { accept( 'error', - `'emit link("${name}")' must sit in the same process, subprocess, ` + - "or handler body as its 'await link': a link cannot cross a " + - "subprocess or handler boundary, the same way a 'goto' cannot.", + diagnosticText( + `'emit link("${name}")' and its 'await link' sit in different scopes`, + "note: a link cannot cross a subprocess or handler boundary, the same way a 'goto' cannot", + 'help: put both in the same process, subprocess, or handler body', + ), at(throwEvent), ); continue; @@ -1812,8 +2183,10 @@ export class BpmnScriptValidator { if (thrown.has(nameOf(catchEvent))) continue; accept( 'warning', - `No 'emit link("${nameOf(catchEvent)}")' names this catch, so it ` + - 'and the steps after it never run.', + diagnosticText( + `no 'emit link("${nameOf(catchEvent)}")' names this catch`, + 'note: the catch and the steps after it never run', + ), at(catchEvent), ); } @@ -1822,6 +2195,7 @@ export class BpmnScriptValidator { /** Each binds the same process variable; a catch binding always fills a `string`. */ private checkFormVariableAgreement( process: Process, + nodes: readonly AstNode[], accept: ValidationAcceptor, ): void { const declaredType = new Map(); @@ -1841,7 +2215,7 @@ export class BpmnScriptValidator { declaredType.set(decl.name, decl.type); } } - for (const node of AstUtils.streamAst(process)) { + for (const node of nodes) { if (isOnHandler(node)) { for (const binding of caughtBindingsOf(node.items)) { if (binding.variable === undefined) continue; @@ -1849,7 +2223,7 @@ export class BpmnScriptValidator { 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.`, + `catch-binding variable '${binding.variable}' is typed 'string', but '${binding.variable}' is already declared as '${prior}'`, { node: binding.node, property: 'value' }, ); } @@ -1865,7 +2239,7 @@ export class BpmnScriptValidator { 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.`, + `form field '${field.id}' is typed '${field.type}', but '${field.id}' is already declared as '${prior}'`, { node: field, property: 'type' }, ); } @@ -1886,16 +2260,19 @@ export class BpmnScriptValidator { 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.`, + diagnosticText( + `statement name '${node.name}' is the id generated for the implicit ${role} event of '${container}'`, + 'help: 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 the ` + - 'desugarer or the layouter generates.', + diagnosticText( + `statement name '${node.name}' matches a reserved id pattern`, + `note: the shapes ${RESERVED_ID_SHAPE_LIST} are reserved for ids the compiler generates`, + ), { node, property: 'name' }, ); } @@ -1915,7 +2292,7 @@ export class BpmnScriptValidator { (decl) => accept( 'error', - `Variable '${decl.name}' is already declared in process '${process.name}'.`, + `variable '${decl.name}' is already declared in process '${process.name}'`, { node: decl, property: 'name' }, ), ); @@ -1958,8 +2335,10 @@ export class BpmnScriptValidator { (node) => accept( 'error', - `Step name '${node.name}' is already used by another step in process ` + - `'${process.name}'; 'goto ${node.name}' would be ambiguous.`, + diagnosticText( + `step name '${node.name}' is already used by another step in process '${process.name}'`, + `note: 'goto ${printName(node.name)}' would be ambiguous`, + ), { node, property: 'name' }, ), ); @@ -1969,8 +2348,10 @@ export class BpmnScriptValidator { 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.', + diagnosticText( + `step name '${node.name}' equals the process id`, + 'note: the compiled document can hold only one element with that id', + ), { node, property: 'name' }, ); } @@ -2006,7 +2387,17 @@ export class BpmnScriptValidator { } const container = expr.$container; - if (isVarRef(expr)) { + if (isVarRef(expr) && isQuotedOutsideCode(expr)) { + accept( + 'error', + diagnosticText( + `'${expr.ref.$refText}' in backticks is not a variable`, + 'note: backticks spell the name of a step, a process or a code', + `help: to refer to a variable named '${expr.ref.$refText}', write the quoted raw expression "\${${expr.ref.$refText}}"`, + ), + { node: expr, property: 'ref' }, + ); + } else if (isVarRef(expr)) { const nameTrigger = nameTriggerOf(expr); const variableUse = isVariableUse(expr); if (nameTrigger !== undefined) { @@ -2029,7 +2420,10 @@ export class BpmnScriptValidator { if (variableUse && !symbols.has(expr.ref.$refText)) { accept( 'warning', - `Variable '${expr.ref.$refText}' is not declared. Add 'var ${expr.ref.$refText}: ' to the process.`, + diagnosticText( + `variable '${expr.ref.$refText}' is not declared`, + `help: add 'var ${expr.ref.$refText}: ' to the process`, + ), { node: expr, property: 'ref' }, ); } @@ -2038,6 +2432,12 @@ export class BpmnScriptValidator { if (isRawExpr(expr)) { checkRawTemplateNames(expr.raw, { node: expr, property: 'raw' }, accept); + if (nestsTemplate(expr.raw)) { + accept('error', NESTED_TEMPLATE_MESSAGE, { + node: expr, + property: 'raw', + }); + } // A raw's body is spliced into the surrounding expression; the top is rendered as written. if ( (isExpr(container) || isAccessor(container)) && @@ -2117,7 +2517,7 @@ export class BpmnScriptValidator { if (!allowed.has(type)) { accept( 'error', - `Variable '${operand.ref.$refText}' of type '${type}' cannot be used in ${context} (operator '${node.op}').`, + `variable '${operand.ref.$refText}' of type '${type}' cannot be used in ${context} with '${node.op}'`, { node, property: side }, ); } @@ -2201,7 +2601,7 @@ export class BpmnScriptValidator { if (rule.code === 'required' && !name) { accept( 'error', - nameRequiredMessage(`A ${start.trigger} start`, start.trigger!), + nameRequiredMessage(`a ${start.trigger} start`, start.trigger!), { node: start, property: 'trigger' }, ); } @@ -2219,13 +2619,7 @@ export class BpmnScriptValidator { } this.checkConditionPayload(start, rule, 'start', accept); - - this.checkTimerClause( - start, - rule.timer, - particleOnlyMessage('a timer start'), - accept, - ); + this.checkTimerClause(start, rule.timer, 'start', accept); } checkEndEvent = (end: EndEvent, accept: ValidationAcceptor): void => { @@ -2262,13 +2656,6 @@ export class BpmnScriptValidator { } }; - checkAttributeOwner = ( - owner: AttributeOwner, - accept: ValidationAcceptor, - ): void => { - this.checkAttributeBlock(owner, accept); - }; - checkUserTask = (task: UserTask, accept: ValidationAcceptor): void => { this.checkAttributeBlock(task, accept); this.checkFormReference(task, accept); @@ -2293,8 +2680,7 @@ export class BpmnScriptValidator { accept('error', FORM_REF_BINDING_MESSAGE, target); return; } - this.checkBindingAttribute(task, accept); - this.checkBindingVersionExclusion(task, 'A user task', accept); + this.checkVersionPin(task, 'a user task', accept); } /** The engine runs a send task as a service task. */ @@ -2306,7 +2692,7 @@ export class BpmnScriptValidator { if (task.name === undefined) return; this.checkImplementationBinding( task, - `${isServiceTask(task) ? 'Service' : 'Send'} task '${task.name}'`, + `${isServiceTask(task) ? 'service' : 'send'} task '${task.name}'`, SERVICE_TASK_BINDING_KEYS, accept, ); @@ -2321,14 +2707,13 @@ export class BpmnScriptValidator { if (task.name !== undefined) { this.checkImplementationBinding( task, - `Decision step '${task.name}'`, + `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.checkVersionPin(task, 'a decision step', accept); this.checkDecisionResultMapping(task, accept); }; @@ -2465,7 +2850,7 @@ export class BpmnScriptValidator { if (value === undefined || !DECISION_RESULT_MAPPING_SET.has(value)) { accept( 'error', - `Setting 'mapDecisionResult' must be ${formatWordList(DECISION_RESULT_MAPPINGS)}.`, + `setting 'mapDecisionResult' must be ${formatWordList(DECISION_RESULT_MAPPINGS)}`, { node: attr, property: 'value' }, ); return; @@ -2489,8 +2874,10 @@ export class BpmnScriptValidator { // 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 ` + - 'script body; a script must be a closed ``` ... ``` block.', + diagnosticText( + `script task '${task.name}' has a malformed or unterminated fenced script body`, + 'note: a script is a closed ``` ... ``` block', + ), { node: task, property: 'name' }, ); return; @@ -2498,7 +2885,7 @@ export class BpmnScriptValidator { checkFencedScript( task.body, - `Script task '${task.name}'`, + `script task '${task.name}'`, { node: task, property: 'body' }, accept, ); @@ -2512,7 +2899,10 @@ export class BpmnScriptValidator { forms.slice(1).forEach((form) => { accept( 'error', - `${ownerDescription} may declare at most one 'form' block.`, + diagnosticText( + `${ownerDescription} has a second 'form' block`, + "note: it may declare one 'form' block at most", + ), { node: form }, ); }); @@ -2522,7 +2912,7 @@ export class BpmnScriptValidator { form.fields, (field) => field.id, (field) => - accept('error', `Duplicate form field '${field.id}'.`, { + accept('error', `duplicate form field '${field.id}'`, { node: field, property: 'id', }), @@ -2531,7 +2921,10 @@ export class BpmnScriptValidator { if (field.type !== undefined && !FORM_FIELD_TYPE_SET.has(field.type)) { accept( 'error', - `Form field '${field.id}' has type '${field.type}', which a form cannot use. Use ${formatPlainWordList(FORM_FIELD_TYPES)}.`, + diagnosticText( + `form field '${field.id}' has type '${field.type}', which a form cannot use`, + `help: use ${formatPlainWordList(FORM_FIELD_TYPES)}`, + ), { node: field, property: 'type' }, ); } @@ -2667,7 +3060,10 @@ export class BpmnScriptValidator { for (const form of forms) { accept( 'error', - `${description} cannot declare a 'form' block; forms belong on start events and user tasks.`, + diagnosticText( + `${description} cannot declare a 'form' block`, + 'note: forms belong on start events and user tasks', + ), { node: form }, ); } @@ -2680,7 +3076,15 @@ export class BpmnScriptValidator { description: string, accept: ValidationAcceptor, ): void { - this.checkAllowedKeys(attrs, allowed, description, accept); + for (const attr of attrs) { + if (!allowed.has(attr.key)) { + accept( + 'error', + `setting '${attr.key}' is not valid on ${description}`, + { node: attr, property: 'key' }, + ); + } + } this.checkAttributeValues(attrs, allowed, accept); } @@ -2697,7 +3101,7 @@ export class BpmnScriptValidator { if (written.length === 0) { accept( 'error', - `${subject} must declare a ${formatWordList(keys)} setting${alternative}.`, + `${subject} has no ${formatWordList(keys)} setting${alternative}`, target, ); return false; @@ -2714,7 +3118,7 @@ export class BpmnScriptValidator { attrs, (attr) => attr.key, (attr) => - accept('error', `Duplicate setting '${attr.key}'.`, { + accept('error', `duplicate setting '${attr.key}'`, { node: attr, property: 'key', }), @@ -2733,33 +3137,13 @@ export class BpmnScriptValidator { ): void { for (const flag of flagsOf(items)) { if (legal.includes(flag.flag)) continue; - accept('error', `Flag '${flag.flag}' is not valid on ${description}.`, { + accept('error', `flag '${flag.flag}' is not valid on ${description}`, { node: flag, property: 'flag', }); } } - private checkAllowedKeys( - attrs: readonly Setting[], - allowed: ReadonlySet, - description: string, - accept: ValidationAcceptor, - ): void { - for (const attr of attrs) { - if (!allowed.has(attr.key)) { - accept( - 'error', - `Setting '${attr.key}' is not valid on ${description}.`, - { - node: attr, - property: 'key', - }, - ); - } - } - } - /** * A boolean flag or engine text field takes one shape and drops the rest * silently: `asyncBefore: "true"` emits no attribute at all. @@ -2785,8 +3169,10 @@ export class BpmnScriptValidator { ) { accept( 'error', - `Setting '${attr.key}' takes a quoted string or a "\${...}" ` + - 'expression; put the value in quotes.', + diagnosticText( + `setting '${attr.key}' is not a quoted string or a "\${...}" expression`, + 'help: put the value in quotes', + ), { node: attr, property: 'value' }, ); } else if ( @@ -2823,16 +3209,16 @@ export class BpmnScriptValidator { } else { const binding = bindingValueMessage(attr.key, attr.value); if (binding !== undefined) { - accept('error', binding, { node: attr, property: 'value' }); + accept(binding[0], binding[1], { node: attr, property: 'value' }); } } } } - private checkAttributeBlock( + checkAttributeBlock = ( owner: AttributeOwner, accept: ValidationAcceptor, - ): void { + ): void => { const rule = attributeBlockRuleOf(owner)!; this.checkDuplicateKeys(settingsOf(owner.items), accept); const settings = configuredSettingsOf(owner); @@ -2845,20 +3231,71 @@ export class BpmnScriptValidator { accept, ); this.checkRunSettings(owner, rule, settings, accept); + this.checkRepeatDocumentation(owner, settings, accept); this.checkJobSettingPairing(owner, rule, settings, accept); this.checkFlags(owner.items, rule.flags, rule.description, accept); if ('forms' in owner) { if (rule.forms) { this.checkFormBlocks(owner.forms, rule.description, accept); } else { - this.rejectFormBlock(owner.forms, capitalize(rule.description), accept); + this.rejectFormBlock(owner.forms, rule.description, accept); } } this.checkIoParameters(owner, rule, accept); this.checkExternalExtras(owner, rule, accept); this.checkListeners(owner, rule, accept); + }; + + private checkRepeatDocumentation( + owner: AttributeOwner, + settings: readonly Setting[], + accept: ValidationAcceptor, + ): void { + const clause = owner as { cardinality?: Expr; element?: string }; + for (const setting of settings) { + const message = + setting.key === VALUE_DOCUMENTATION_KEYS.count && + clause.cardinality === undefined + ? COUNT_DOCUMENTATION_MESSAGE + : setting.key === VALUE_DOCUMENTATION_KEYS.each && + clause.element === undefined + ? EACH_DOCUMENTATION_MESSAGE + : undefined; + if (message !== undefined) { + accept('error', message, { node: setting, property: 'key' }); + } + } } + /** The parentheses around a condition hold the condition's documentation alone. */ + checkConditionItem = (item: ParenItem, accept: ValidationAcceptor): void => { + const property = item.$containerProperty; + if (property !== 'conditionItems' && property !== 'completionItems') { + return; + } + const key = VALUE_DOCUMENTATION_KEYS.condition; + if (!isSetting(item) || item.key !== key) { + const message = + isSetting(item) && item.key === DOCUMENTATION_KEY + ? CONDITION_DOCUMENTATION_KEY_MESSAGE + : CONDITION_ITEM_MESSAGE; + accept('error', message, { node: item }); + return; + } + const siblings = ( + item.$container as unknown as Record + )[property]!; + const first = siblings.find( + (sibling) => isSetting(sibling) && sibling.key === key, + ); + if (first !== item) { + accept('error', `duplicate setting '${key}'`, { + node: item, + property: 'key', + }); + } + }; + private checkRunSettings( owner: AttributeOwner, rule: AttributeBlockRule, @@ -2967,7 +3404,7 @@ export class BpmnScriptValidator { const refusal = namesFieldBinding(settings) ? undefined : fieldBindingMessage( - capitalize(rule.description), + rule.description, fieldlessBindingsOf(settings), ELEMENT_FIELD_BINDINGS, ); @@ -3006,11 +3443,10 @@ export class BpmnScriptValidator { }); } } else if (!rule.parameters) { - accept( - 'error', - `${capitalize(rule.description)} cannot declare an 'input' or 'output' parameter; ${PARAMETER_HOSTS_MESSAGE}`, - { node: param, property: 'direction' }, - ); + accept('error', noParameterHostMessage(rule.description), { + node: param, + property: 'direction', + }); } else if (IO_DIRECTION_SET.has(param.direction)) { recognized.push(param); } else { @@ -3086,7 +3522,7 @@ export class BpmnScriptValidator { (param) => accept( 'error', - `Duplicate '${param.direction}' parameter '${param.name}'.`, + `duplicate '${param.direction}' parameter '${param.name}'`, { node: param, property: 'name' }, ), ); @@ -3149,13 +3585,16 @@ export class BpmnScriptValidator { accept: ValidationAcceptor, ): void { if (param.value === undefined) return; - const subject = `${capitalize(param.direction)} '${param.name}'`; + const subject = `${param.direction} '${param.name}'`; 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.`, + diagnosticText( + 'this map entry has an empty key', + 'help: name the key its value is looked up by', + ), { node, property: 'key' }, ); } else if (isLiteralString(node) && node.value.trim().length === 0) { @@ -3206,15 +3645,20 @@ export class BpmnScriptValidator { } accept( 'error', - `'on ${listener.event}' is a task listener, which only a user task has; ` + - `${rule.description} takes ${formatWordList(EXECUTION_LISTENER_EVENTS)}.`, + diagnosticText( + `'on ${listener.event}' is a task listener, which only a user task has`, + `note: ${rule.description} takes ${formatWordList(EXECUTION_LISTENER_EVENTS)}`, + ), { node: listener, property: 'event' }, ); return false; } accept( 'error', - `Unknown listener event '${listener.event}'; write ${formatWordList(listenerEventsFor(rule))}.`, + diagnosticText( + `unknown listener event '${listener.event}'`, + `help: write ${formatWordList(listenerEventsFor(rule))}`, + ), { node: listener, property: 'event' }, ); return false; @@ -3228,7 +3672,7 @@ export class BpmnScriptValidator { listener: Listener, accept: ValidationAcceptor, ): void { - const subject = `The 'on ${listener.event}' listener`; + const subject = `the 'on ${listener.event}' listener`; const settings = settingsOf(listener.items); // A fenced body binds the listener in place of its settings. const refusal = @@ -3266,7 +3710,7 @@ export class BpmnScriptValidator { if (listener.script !== undefined) { checkFencedScript( listener.script, - `The 'on ${listener.event}' listener`, + `the 'on ${listener.event}' listener`, { node: listener, property: 'script' }, accept, ); @@ -3274,7 +3718,11 @@ export class BpmnScriptValidator { } this.checkAttributeKeys( - settingsOf(listener.items), + settingsOf(listener.items).filter( + (setting) => + listener.event !== 'timeout' || + setting.key !== VALUE_DOCUMENTATION_KEYS.timer, + ), LISTENER_BINDING_KEY_SET, 'a listener', accept, @@ -3283,10 +3731,10 @@ export class BpmnScriptValidator { this.checkExactlyOneBinding( settingsOf(listener.items), LISTENER_BINDING_KEYS, - `The 'on ${listener.event}' listener`, + `the 'on ${listener.event}' listener`, { node: listener, property: 'event' }, accept, - ', or a fenced script body', + ', nor a fenced script body', ); } @@ -3367,17 +3815,21 @@ export class BpmnScriptValidator { checkIfStatement = (stmt: IfStatement, accept: ValidationAcceptor): void => { this.checkGatewaySettings(stmt, accept); + checkElseFailWord(stmt, accept); + // Beside `else fail` an empty branch is how a case carries on rather than + // failing, so it is no slip. + if (stmt.fail !== undefined) return; this.checkEmptyBlock( stmt.then, 'warning', - "The 'if' branch has no steps.", + "the 'if' branch has no steps", accept, ); for (const elseIf of stmt.elseIfs) { this.checkEmptyBlock( elseIf.body, 'warning', - "The 'else if' branch has no steps.", + "the 'else if' branch has no steps", accept, ); } @@ -3385,7 +3837,7 @@ export class BpmnScriptValidator { this.checkEmptyBlock( stmt.elseBlock, 'warning', - "The 'else' branch has no steps.", + "the 'else' branch has no steps", accept, ); } @@ -3429,12 +3881,12 @@ export class BpmnScriptValidator { for (const param of block.params) { accept( 'error', - blockMemberMessage(`An '${param.direction}' parameter`, 'configures'), + blockMemberMessage(`an '${param.direction}' parameter`, 'configures'), { node: param, property: 'direction' }, ); } for (const listener of block.listeners) { - accept('error', blockMemberMessage('A listener', 'observes'), { + accept('error', blockMemberMessage('a listener', 'observes'), { node: listener, property: 'event', }); @@ -3448,7 +3900,10 @@ export class BpmnScriptValidator { const kind = describeStatementKind(stmt); accept( 'error', - `${capitalize(kind)} named '${stmt.name}' has no flow steps: ${kind} needs at least one step on its main flow (handlers alone do not start it).`, + diagnosticText( + `${kind} named '${stmt.name}' has no flow steps`, + `note: ${kind} needs at least one step on its main flow, and handlers alone do not start it`, + ), { node: stmt, property: 'name' }, ); } @@ -3481,10 +3936,11 @@ export class BpmnScriptValidator { ): void => { this.checkGatewaySettings(stmt, accept); stmt.branches.forEach((branch, index) => { + if (stmt.fail !== undefined && branch.condition !== undefined) return; this.checkEmptyBlock( branch.body, 'warning', - `Branch ${index + 1} of the 'parallel' statement has no steps.`, + `branch ${index + 1} of the 'parallel' statement has no steps`, accept, ); }); @@ -3502,10 +3958,29 @@ export class BpmnScriptValidator { property: 'otherwise', }); } + const conditioned = stmt.branches.some( + (branch) => branch.condition !== undefined, + ); + const unconditioned = stmt.branches.some( + (branch) => branch.condition === undefined && !branch.otherwise, + ); + if (stmt.fail !== undefined) { + checkElseFailWord(stmt, accept); + const message = !conditioned + ? ELSE_FAIL_WITHOUT_CONDITION_MESSAGE + : unconditioned + ? ELSE_FAIL_BESIDE_UNCONDITIONED_MESSAGE + : fallbacks.length > 0 + ? ELSE_FAIL_BESIDE_ELSE_MESSAGE + : undefined; + if (message !== undefined) { + accept('error', message, { node: stmt, property: 'fail' }); + } + } if (fallbacks.length === 0) return; - const message = !hasConditionedBranch(stmt) + const message = !conditioned ? PARALLEL_ELSE_WITHOUT_CONDITION_MESSAGE - : hasUnconditionedBranch(stmt) + : unconditioned ? PARALLEL_ELSE_BESIDE_UNCONDITIONED_MESSAGE : undefined; if (message !== undefined) { @@ -3526,7 +4001,7 @@ export class BpmnScriptValidator { this.checkEmptyBlock( branch.body, 'warning', - `Branch ${index + 1} of the 'await' statement has no steps.`, + `branch ${index + 1} of the 'await' statement has no steps`, accept, ); this.checkCatchTrigger(branch, accept); @@ -3568,8 +4043,10 @@ export class BpmnScriptValidator { if (isLinkCatch(target)) { accept( 'error', - `'goto ${targetName}' cannot target an awaited link: a link catch is ` + - "entered by 'emit link' of the same name, not by a sequence flow.", + diagnosticText( + `'goto ${targetName}' targets an awaited link`, + "note: a link catch is entered by 'emit link' of the same name, not by a sequence flow", + ), { node: goto, property: 'target' }, ); return; @@ -3593,8 +4070,7 @@ export class BpmnScriptValidator { ): void => { this.checkAttributeBlock(call, accept); this.checkCallProcessAttribute(call, accept); - this.checkBindingAttribute(call, accept); - this.checkBindingVersionExclusion(call, 'A call', accept); + this.checkVersionPin(call, 'a call', accept); this.checkCallMapperExclusion(call, accept); this.checkCallMappingDuplicates(call, accept); }; @@ -3606,52 +4082,43 @@ export class BpmnScriptValidator { if (settingsOf(call.items).some((a) => a.key === 'process')) return; accept( 'error', - `A call must name the process it starts: add process: "".`, + diagnosticText( + "this call has no 'process' setting naming the process it starts", + `help: add 'process: ""'`, + ), { node: call, property: 'name' }, ); } /** Bare `version` parses as a variable reference, quoted as a string; both read the same text. */ - private checkBindingAttribute( + private checkVersionPin( owner: VersionPinnedElement, + subject: string, accept: ValidationAcceptor, ): void { - const bindingAttr = settingsOf(owner.items).find( - (a) => a.key === 'binding', - ); - if (!bindingAttr) { - return; - } - const value = bindingValueText(bindingAttr.value); - if (value !== undefined && CALL_BINDING_VALUE_SET.has(value)) { - return; - } - if (value === 'version') { + const settings = settingsOf(owner.items); + const binding = settings.find((a) => a.key === 'binding'); + if (binding === undefined) return; + const value = bindingValueText(binding.value); + if (value === undefined || !CALL_BINDING_VALUE_SET.has(value)) { accept( 'error', - `Write 'version: ' instead of 'binding: version'.`, - { node: bindingAttr, property: 'value' }, + value === 'version' + ? diagnosticText( + "'binding: version' is not a binding mode", + "help: write 'version: '", + ) + : `setting 'binding' must be ${formatWordList(CALL_BINDING_VALUES)}`, + { node: binding, property: 'value' }, ); - return; } - accept( - 'error', - `Setting 'binding' must be ${formatWordList(CALL_BINDING_VALUES)}.`, - { node: bindingAttr, property: 'value' }, - ); - } - - private checkBindingVersionExclusion( - owner: VersionPinnedElement, - subject: string, - accept: ValidationAcceptor, - ): void { - const hasBinding = settingsOf(owner.items).some((a) => a.key === 'binding'); - const hasVersion = settingsOf(owner.items).some((a) => a.key === 'version'); - if (hasBinding && hasVersion) { + if (settings.some((a) => a.key === 'version')) { accept( 'error', - `${subject} cannot combine 'binding' and 'version'; use 'version: ' to pin a specific version, or ${BINDING_MODE_PHRASES.join('/')} for the other modes.`, + diagnosticText( + `${subject} combines 'binding' and 'version'`, + `help: write 'version: ' to pin a specific version, or ${formatPlainWordList(BINDING_MODE_PHRASES)} for the other modes`, + ), { node: owner, property: 'name' }, ); } @@ -3665,7 +4132,7 @@ export class BpmnScriptValidator { checkAtMostOneBinding( settingsOf(call.items), Object.values(CALL_MAPPER_KEY_BY_KIND), - 'A call', + 'a call', { node: call, property: 'name' }, accept, ); @@ -3684,8 +4151,11 @@ export class BpmnScriptValidator { accept( 'error', mapping.all - ? `Duplicate '${mapping.direction} *' mapping; a direction can copy every variable only once.` - : `Duplicate '${mapping.direction}' mapping target '${mapping.target}'.`, + ? diagnosticText( + `duplicate '${mapping.direction} *' mapping`, + 'note: a direction can copy every variable only once', + ) + : `duplicate '${mapping.direction}' mapping target '${mapping.target}'`, mapping.all ? { node: mapping } : { node: mapping, property: 'target' }, @@ -3730,7 +4200,7 @@ export class BpmnScriptValidator { this.checkEmptyBlock( handler.body, 'warning', - 'The event handler has no steps.', + 'the event handler has no steps', accept, ); }; @@ -3747,7 +4217,7 @@ export class BpmnScriptValidator { if (!code) { accept( 'error', - nameRequiredMessage(`A ${handler.trigger} handler`, handler.trigger), + nameRequiredMessage(`a ${handler.trigger} handler`, handler.trigger), { node: handler, property: 'trigger' }, ); } @@ -3768,13 +4238,7 @@ export class BpmnScriptValidator { } this.checkConditionPayload(handler, rule, 'handler', accept); - - this.checkTimerClause( - handler, - rule.timer, - particleOnlyMessage("'on timer'"), - accept, - ); + this.checkTimerClause(handler, rule.timer, 'handler', accept); const handlerBindings = caughtBindingsOf(handler.items); if (rule.parens === 'forbidden' && handlerBindings.length > 0) { @@ -3783,7 +4247,10 @@ export class BpmnScriptValidator { 'error', handler.trigger === 'compensation' ? COMPENSATION_BINDINGS_MESSAGE - : `'(code: c)' bindings belong to error and escalation handlers; a ${handler.trigger} carries no code.`, + : diagnosticText( + `a ${handler.trigger} carries no code to bind`, + "note: '(code: c)' bindings belong to error and escalation handlers", + ), { node: binding.node, property: 'key' }, ); } @@ -3793,30 +4260,37 @@ export class BpmnScriptValidator { private checkConditionPayload( node: CatchHeader | OnHandler | StartEvent, rule: TriggerPayloadRule, - position: ConditionPosition, + position: PayloadPosition, accept: ValidationAcceptor, ): void { - const { subject, clause, only } = CONDITION_PHRASING[position]; + const { subject, clause, only } = PAYLOAD_PHRASING[position]; const form = `${clause}(amount > 100)`; const payload = payloadItemOf(node.items); if (rule.parens === 'condition' && payload === undefined) { - accept('error', `${subject} needs its condition: '${form}'.`, { - node, - property: 'trigger', - }); + accept( + 'error', + diagnosticText(`${subject} has no condition`, `help: write '${form}'`), + { + node, + property: 'trigger', + }, + ); } if (node.trigger === 'condition' && hasQuotedPayload(node.items)) { accept( 'error', - `${subject} takes no code string; write the condition itself: '${form}'.`, + diagnosticText( + `${subject} holds a quoted string instead of a condition`, + `help: write the condition itself: '${form}'`, + ), { node: payload!, property: 'value' }, ); } if (node.trigger !== 'condition' && hasExpressionPayload(node.items)) { - accept('error', `Only ${only} takes a condition expression.`, { + accept('error', `only ${only} takes a condition expression`, { node: payload!, property: 'value', }); @@ -3830,13 +4304,16 @@ export class BpmnScriptValidator { private checkTimerClause( node: CatchHeader | OnHandler | StartEvent, required: boolean, - particleOnly: string, + position: PayloadPosition, accept: ValidationAcceptor, ): void { if (!required) { const keyed = timerParticleOf(node.items); if (keyed !== undefined) { - accept('error', particleOnly, { node: keyed.node, property: 'key' }); + accept('error', particleOnlyMessage(PAYLOAD_PHRASING[position].timer), { + node: keyed.node, + property: 'key', + }); } return; } @@ -3884,7 +4361,10 @@ export class BpmnScriptValidator { } else if (known === undefined) { accept( 'error', - `Unknown timer particle '${particle}'; write ${formatWordList(TIMER_PARTICLES)}.`, + diagnosticText( + `unknown timer particle '${particle}'`, + `help: write ${formatWordList(TIMER_PARTICLES)}`, + ), { node: listener, property: 'particle' }, ); } else if (listener.time !== undefined) { @@ -3926,8 +4406,10 @@ export class BpmnScriptValidator { if (timer.particle === 'every' && !hasFlag(handler.items, 'alongside')) { accept( 'warning', - 'A repeating timer that interrupts its scope fires at most once: ' + - "add 'alongside' to let it repeat, or give it a duration instead.", + diagnosticText( + 'a repeating timer that interrupts its scope fires at most once', + "help: add 'alongside' to let it repeat, or give it a duration instead", + ), { node: timer.node }, ); } @@ -3953,7 +4435,11 @@ export class BpmnScriptValidator { } accept( 'error', - '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 whole scope, not for a single branch.', + diagnosticText( + 'this event handler sits inside a branch or loop body', + 'note: a handler handles events for a whole scope, not for a single branch', + 'note: it belongs directly in the body of a process, a subprocess, an attempt block, or another event handler', + ), { node: handler }, ); } @@ -3970,12 +4456,41 @@ export class BpmnScriptValidator { if (hasNonHandlerAfter) { accept( 'error', - 'Event handlers read like catch blocks: move it after the last step of this body.', + diagnosticText( + 'this event handler comes before a step of its body', + 'note: event handlers read like catch blocks', + 'help: move it after the last step of this body', + ), { node: handler }, ); } } + /** + * A field or property key is any string to the engine, but an 'input' or + * 'output' name is the variable it sets, which an expression reads plain. + */ + checkIoParameterName(param: IoParameter, accept: ValidationAcceptor): void { + if ( + (param.direction !== INPUT_DIRECTION && + param.direction !== OUTPUT_DIRECTION) || + GrammarUtils.findNodeForProperty(param.$cstNode, 'name')?.text.startsWith( + '`', + ) !== true + ) { + return; + } + accept( + 'error', + diagnosticText( + `'${param.name}' in backticks is not a variable`, + `note: an '${param.direction}' parameter names the variable it sets`, + 'note: backticks spell a key, or the name of a step, a process or a code', + ), + { node: param, property: 'name' }, + ); + } + private checkHandlerBindings( handler: OnHandler, accept: ValidationAcceptor, @@ -3993,7 +4508,11 @@ export class BpmnScriptValidator { // `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}: '.`, + diagnosticText( + 'this catch binding names a value instead of a variable', + `note: a catch binding names the variable the caught ${binding.field} lands in`, + `help: write '${binding.field}: '`, + ), { node: binding.node, property: 'value' }, ); } @@ -4069,11 +4588,11 @@ export class BpmnScriptValidator { * beside a coded one. */ private checkHandlerDuplicates( - process: Process, + nodes: readonly AstNode[], accept: ValidationAcceptor, ): void { const candidates: OnHandler[] = []; - for (const node of AstUtils.streamAst(process)) { + for (const node of nodes) { if (!isOnHandler(node)) continue; if (!ON_TRIGGERS_SET.has(node.trigger)) continue; // Timer and condition key no subscription name, and two deadlines in one @@ -4127,42 +4646,37 @@ export class BpmnScriptValidator { stmt: ThrowStatement, accept: ValidationAcceptor, ): void => { - this.checkAttributeBlock(stmt, accept); - - if (stmt.trigger === undefined) return; - - if (!THROW_TRIGGERS_SET.has(stmt.trigger)) { - accept('error', throwTriggerMessage(stmt.trigger), { - node: stmt, - property: 'trigger', - }); - return; - } - checkThrowEmitCode(stmt, 'A thrown', 'throw', accept); - checkThrowEmitBinding(stmt, 'a thrown', accept); + this.checkRaisedEvent(stmt, accept); }; checkEmitStatement = ( stmt: EmitStatement, accept: ValidationAcceptor, ): void => { - this.checkAttributeBlock(stmt, accept); - - if (stmt.trigger === undefined) return; + if (this.checkRaisedEvent(stmt, accept) && stmt.trigger === 'link') { + this.checkLinkThrowItems(stmt, accept); + } + }; - if (!EMIT_TRIGGERS_SET.has(stmt.trigger)) { - accept('error', emitTriggerMessage(stmt.trigger), { + /** Returns whether the trigger is one the statement raises. */ + private checkRaisedEvent( + stmt: ThrowStatement | EmitStatement, + accept: ValidationAcceptor, + ): boolean { + this.checkAttributeBlock(stmt, accept); + if (stmt.trigger === undefined) return false; + const raise = RAISE_PHRASING[stmt.$type]; + if (!raise.triggers.has(stmt.trigger)) { + accept('error', raise.triggerMessage(stmt.trigger), { node: stmt, property: 'trigger', }); - return; - } - checkThrowEmitCode(stmt, 'An emitted', 'emit', accept); - checkThrowEmitBinding(stmt, 'an emitted', accept); - if (stmt.trigger === 'link') { - this.checkLinkThrowItems(stmt, accept); + return false; } - }; + checkThrowEmitCode(stmt, raise, accept); + checkThrowEmitBinding(stmt, raise, accept); + return true; + } /** A link throw gets no activity, so its settings and listeners parse into nothing. */ private checkLinkThrowItems( @@ -4171,7 +4685,7 @@ export class BpmnScriptValidator { ): void { for (const setting of configuredSettingsOf(stmt)) { if (!ENGINE_KEY_SET.has(setting.key)) continue; - accept('error', linkThrowNeverRunsMessage(`Setting '${setting.key}'`), { + accept('error', linkThrowNeverRunsMessage(`setting '${setting.key}'`), { node: setting, property: 'key', }); @@ -4179,7 +4693,7 @@ export class BpmnScriptValidator { for (const listener of stmt.listeners) { accept( 'error', - linkThrowNeverRunsMessage(`The 'on ${listener.event}' listener`), + linkThrowNeverRunsMessage(`the 'on ${listener.event}' listener`), { node: listener }, ); } @@ -4199,7 +4713,12 @@ export class BpmnScriptValidator { * trigger word. */ checkParenValue = (value: ParenValue, accept: ValidationAcceptor): void => { - const owner = value.$container; + // Beside a condition, `checkConditionItem` refuses it. + if (value.$containerProperty !== 'items') return; + const owner = value.$container as Exclude< + ParenValue['$container'], + ElseIf | ParallelBranch + >; if (payloadItemOf(owner.items) !== value) { accept('error', SECOND_PAREN_VALUE_MESSAGE, { node: value }); return; @@ -4247,7 +4766,7 @@ export class BpmnScriptValidator { accept( 'error', nameRequiredMessage( - `An awaited ${catchEvent.trigger}`, + `an awaited ${catchEvent.trigger}`, catchEvent.trigger, ), { node: catchEvent, property: 'trigger' }, @@ -4255,13 +4774,7 @@ export class BpmnScriptValidator { } this.checkConditionPayload(catchEvent, rule, 'catch', accept); - - this.checkTimerClause( - catchEvent, - rule.timer, - particleOnlyMessage("'await timer'"), - accept, - ); + this.checkTimerClause(catchEvent, rule.timer, 'catch', accept); } /** @@ -4289,7 +4802,10 @@ export class BpmnScriptValidator { if (declaredNames.has(decl.name)) { accept( 'error', - `'${decl.name}' is already declared in this process; '${decl.kind}(${decl.name})' would be ambiguous.`, + diagnosticText( + `'${decl.name}' is already declared in this process`, + `note: '${decl.kind}(${printName(decl.name)})' would be ambiguous`, + ), { node: decl, property: 'name' }, ); } else { @@ -4306,7 +4822,10 @@ export class BpmnScriptValidator { if (owner.name === decl.name) continue; accept( 'error', - `${capitalize(decl.kind)} code '${code}' is already declared by '${owner.name}'; two declarations cannot share a code.`, + diagnosticText( + `${decl.kind} code '${code}' is already declared by '${owner.name}'`, + 'note: two declarations cannot share a code', + ), { node: decl, property: 'name' }, ); } @@ -4344,13 +4863,13 @@ export class BpmnScriptValidator { } else if (!isLiteralString(setting.value)) { accept( 'error', - `An ${decl.kind} declaration's ${setting.key} must be a quoted string.`, + `the ${setting.key} of an ${decl.kind} declaration is not a quoted string`, { node: setting, property: 'value' }, ); } else if (setting.value.value.length === 0) { accept( 'error', - `An ${decl.kind} declaration's ${setting.key} cannot be empty.`, + `the ${setting.key} of an ${decl.kind} declaration is empty`, { node: setting, property: 'value' }, ); } @@ -4358,8 +4877,29 @@ export class BpmnScriptValidator { } } -function collectExpressions(process: Process): Expr[] { - return AstUtils.streamAst(process).filter(isExpr).toArray(); +function isQuotedOutsideCode(ref: VarRef): boolean { + return ( + ref.ref.$refNode?.text.startsWith('`') === true && !isCodePosition(ref) + ); +} + +/** A name in backticks can spell text that no element id may hold. */ +function checkEngineIds( + named: ReadonlyArray, + accept: ValidationAcceptor, +): void { + for (const node of named) { + if (node.name === undefined || ENGINE_ID_TEXT.test(node.name)) continue; + accept( + 'error', + diagnosticText( + `'${node.name}' is not an id Operaton deploys`, + "note: the name becomes the element's id, and Operaton validates every id against 'BPMN20.xsd' as an 'xs:ID'", + "note: an id is ASCII letters, digits, '_', '-' and '.', starting with a letter or '_'", + ), + { node, property: 'name' }, + ); + } } /** Exported so the printer can warn about an imported id. */ @@ -4380,10 +4920,6 @@ function enclosingContainerName(node: NamedStatement): string | undefined { return container?.name; } -function capitalize(text: string): string { - return text.charAt(0).toUpperCase() + text.slice(1); -} - function checkFencedScript( raw: string, subject: string, @@ -4395,37 +4931,48 @@ function checkFencedScript( if (scriptFormatOf(tag) === undefined) { accept( 'error', - `${subject} has an unsupported language tag '${tag}'. Use ${formatWordList(Object.keys(SCRIPT_FORMAT_ALIASES))}.`, + diagnosticText( + `${subject} has an unsupported language tag '${tag}'`, + `help: use ${formatWordList(Object.keys(SCRIPT_FORMAT_ALIASES))}`, + ), target, ); } + // `ScriptUtil.getScript` checks the source for null only, so the engine + // deploys an empty body and evaluates it to nothing. if (code.trim().length === 0) { - accept('error', `${subject} has an empty script body.`, target); + accept( + 'warning', + diagnosticText( + `${subject} has an empty script body`, + 'note: the engine runs it and it does nothing', + ), + target, + ); } } export function quotedCodeMessage(trigger: string, text: string): string { - const spellable = ID_TEXT.test(text); - const declaration = spellable - ? `${trigger} ${text}` - : `${trigger} (code: ${JSON.stringify(text)})`; - const use = spellable ? text : ''; - return ( - `An ${trigger} code is a declared name, not quoted text. ` + - `Declare '${declaration}' in the process header and write '${trigger}(${use})'.` + const name = printName(text); + return diagnosticText( + `an ${trigger} code is a declared name, not quoted text`, + `help: declare '${trigger} ${name}' in the process header and write '${trigger}(${name})'`, ); } function barewordNameMessage(trigger: string, text: string): string { - return ( - `A ${trigger} name is the text the engine matches by name, not a declared ` + - `name. Write '${trigger}("${text}")'.` + return diagnosticText( + `a ${trigger} name is the text the engine matches by name, not a declared name`, + `help: write '${trigger}("${text}")'`, ); } function unknownTriggerMessage(word: string, legal: readonly string[]): string { - return `Unknown event kind '${word}'; write ${formatWordList(legal)}.`; + return diagnosticText( + `unknown event kind '${word}'`, + `help: write ${formatWordList(legal)}`, + ); } /** @@ -4433,10 +4980,9 @@ function unknownTriggerMessage(word: string, legal: readonly string[]): string { * message names both readings. */ export function unknownDeclarationKindMessage(word: string): string { - return ( - `Unknown declaration kind '${word}'; write ` + - `${formatWordList([...DECLARED_CODE_TRIGGERS])}, or a step keyword if a ` + - 'step was meant.' + return diagnosticText( + `unknown declaration kind '${word}'`, + `help: write ${formatWordList([...DECLARED_CODE_TRIGGERS])}, or a step keyword if a step was meant`, ); } @@ -4452,41 +4998,45 @@ export function onTriggerMessage(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 ` + - 'and starts the process as if none were written. Catch it with ' + - `'on ${word}' inside the scope that raises it.` + return diagnosticText( + `a process cannot start on an ${word}`, + 'note: the engine ignores the trigger and starts the process as if none were written', + `help: catch it with 'on ${word}' inside the scope that raises it`, ); } if (word === 'compensation') { - return ( - "A process cannot start on compensation: it undoes a subprocess's " + - "completed work, so it belongs in an 'on compensation' block inside " + - 'that subprocess.' + return diagnosticText( + 'a process cannot start on compensation', + "note: compensation undoes a subprocess's completed work", + "help: write it as an 'on compensation' block inside that subprocess", ); } if (word === 'conditional') { return CONDITIONAL_TYPO_MESSAGE; } - return `Unknown event kind '${word}'; a start event supports ${formatWordList(START_TRIGGERS)}.`; + return diagnosticText( + `unknown event kind '${word}' on a start event`, + `help: write ${formatWordList(START_TRIGGERS)}`, + ); } export function alongsideMessage(trigger: string): string { if (trigger === 'compensation') return COMPENSATION_ALONGSIDE_MESSAGE; if (trigger === 'cancel') return CANCEL_ALONGSIDE_MESSAGE; - return ( - 'An error always interrupts: the handler takes over from the failed ' + - "scope; 'alongside' is only available for escalations." + return diagnosticText( + "an error handler cannot run 'alongside'", + 'note: an error always interrupts, and the handler takes over from the failed scope', + "note: 'alongside' is only available for escalations", ); } export function endTriggerMessage(word: string): string { if (THROW_TRIGGERS_SET.has(word)) { - const article = /^[aeiou]/.test(word) ? 'An' : 'A'; - return ( - `${article} ${word} is raised with 'throw', not on an end: write ` + - `'throw ${word}' in place of this end. ` + - END_TRIGGERS_MESSAGE + const article = /^[aeiou]/.test(word) ? 'an' : 'a'; + return diagnosticText( + `${article} ${word} is raised with 'throw', not on an end`, + END_TRIGGERS_NOTE, + `help: write 'throw ${word}' in place of this end`, ); } if (word === 'timer') { @@ -4495,19 +5045,19 @@ export function endTriggerMessage(word: string): string { if (word === 'condition' || word === 'conditional') { return END_CONDITION_MESSAGE; } - return ( - `Unknown event kind '${word}'. ${END_TRIGGERS_MESSAGE} Every other kind ` + - "is raised with 'throw'." + return diagnosticText( + `unknown event kind '${word}' on an end event`, + END_TRIGGERS_NOTE, + "note: every other kind is raised with 'throw'", ); } 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 ' + - '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.' + return diagnosticText( + `the message start name "${name}" contains an expression`, + 'note: a process that has not started yet has no variables to evaluate it against, so the engine rejects it', + "note: an expression belongs on an 'on message' handler or an 'await message', which run once the process has variables", + 'help: give the start a fixed name', ); } @@ -4519,14 +5069,22 @@ export function throwTriggerMessage(word: string): string { return CANCEL_NOT_RAISED_MESSAGE; } if (word === 'link') { - return "A link continues at its catch rather than ending the path; write 'emit link'."; + return diagnosticText( + 'a link cannot be thrown', + 'note: a link continues at its catch instead of ending the path', + "help: write 'emit link'", + ); } return unknownTriggerMessage(word, THROW_TRIGGERS); } export function emitTriggerMessage(word: string): string { if (word === 'error') { - return "An error always aborts its path; write 'throw error'."; + return diagnosticText( + 'an error cannot be emitted', + 'note: an error always aborts its path', + "help: write 'throw error'", + ); } if (word === 'compensate') { return COMPENSATE_TYPO_MESSAGE; @@ -4544,11 +5102,12 @@ export function catchTriggerMessage(word: string): string { if (word === 'cancel') { return CANCEL_NOT_AWAITED_MESSAGE; } - return ( - `Unknown event kind '${word}'; intermediate catch supports ${formatWordList(CATCH_TRIGGERS)}. ` + - "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.` + return diagnosticText( + `unknown event kind '${word}' on an intermediate catch`, + `note: an intermediate catch supports ${formatWordList(CATCH_TRIGGERS)}`, + "note: an error or an escalation is raised with 'throw' or 'emit'", + "note: compensation is a subprocess's undo block", + "note: a cancel is written on the end that gives up an 'attempt' block", ); } @@ -4608,25 +5167,24 @@ function cancelHandlerFor(block: SubProcess): OnHandler | undefined { } function cancelHostMessage(host: Statement): string { - return ( - `A cancel handler can only attach to an 'attempt' block: it catches ` + - `that block being given up; '${targetStatementName(host)}' is ${describeStatementKind(host)}.` + return diagnosticText( + `this cancel handler attaches to '${targetStatementName(host)}', which is ${describeStatementKind(host)}`, + "note: a cancel handler can only attach to an 'attempt' block, since it catches that block being given up", ); } 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 ` + - `{ ... }' beside the block to say what happens then.` + return diagnosticText( + `'${name}' gives itself up but nothing catches it`, + 'note: the engine stops with an error the first time that end is reached', + `help: write 'on ${name}: cancel { ... }' beside the block to say what happens then`, ); } 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 ` + - 'remove the handler.' + return diagnosticText( + `nothing inside '${name}' gives it up, so this handler never runs`, + "help: write 'end cancel' on the path that should give the block up, or remove the handler", ); } @@ -4637,63 +5195,62 @@ function describeStatementKind(stmt: Statement): string { function startTriggerInBlockMessage(block: SubProcess): string { const kind = describeStatementKind(block); - return ( - `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." + return diagnosticText( + `the start of ${kind} cannot carry a trigger`, + "note: only the process's own start carries a trigger", + `note: ${kind} is entered from the step before it`, + "help: put the trigger on an 'on' handler inside the block if it should react to an event", ); } function illegalHostMessage(host: Statement): string { - return ( - 'A boundary event can only attach to an activity: a user, service, ' + - `script, send, or receive task, a step, a decision step, a subprocess, ` + - `an attempt block, or a call; '${targetStatementName(host)}' is ${describeStatementKind(host)}.` + return diagnosticText( + `this boundary event attaches to '${targetStatementName(host)}', which is ${describeStatementKind(host)}`, + 'note: a boundary event can only attach to an activity: a user, service, script, send, or receive task, a step, a decision step, a subprocess, an attempt block, or a call', ); } function escalationHostMessage(host: Statement): string { - return ( - 'An escalation boundary can only attach to a subprocess, an attempt ' + - `block, a call, or a user task; '${targetStatementName(host)}' is ` + - `${describeStatementKind(host)}.` + return diagnosticText( + `this escalation boundary attaches to '${targetStatementName(host)}', which is ${describeStatementKind(host)}`, + 'note: an escalation boundary can only attach to a subprocess, an attempt block, a call, or a user task', ); } 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 ` + - 'can never host the event that starts that path.' + return diagnosticText( + `this boundary event attaches to '${hostName}' inside its own escape path`, + `note: '${hostName}' only runs after this handler has already fired, so it can never host the event that starts that path`, ); } 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, " + - '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.' + return diagnosticText( + `'start ${name}' opens a handler that names a host`, + "note: the body runs inside the host's own container and is entered from the boundary event, so it has no start of its own", + 'note: the first step of the body is where the escape path begins', + 'help: remove the start', ); } 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 ' + - 'one of them, and starting it by key fails at runtime.' + return diagnosticText( + `process '${name}' has no default start`, + 'note: with only message, signal, or condition starts, the engine can create an instance only by triggering one of them', + 'note: starting the process by key fails at runtime', ); } -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.'; +export const FORM_NEVER_OFFERED_MESSAGE = diagnosticText( + 'this start form is never shown', + "note: the engine offers a start form only on the process's default start, its plain or timer start", +); -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.'; +export const INITIATOR_SHADOWED_MESSAGE = diagnosticText( + "this 'initiator' setting is never written", + 'note: the engine keeps one initiator per process, and the start parsed last wins', + 'help: move it to the last start, or remove it', +); const NAME_SCOPED_TRIGGERS: ReadonlySet = new Set([ 'message', @@ -4702,15 +5259,15 @@ const NAME_SCOPED_TRIGGERS: ReadonlySet = new Set([ export const HANDLER_DUPLICATE_RULE: Readonly> = { message: - 'Operaton keeps one message subscription per name and scope (BpmnParse.addEventSubscriptionDeclaration), so the deployment fails', + 'Operaton keeps one message subscription per name and scope, so the deployment fails', signal: - 'Operaton keeps one signal subscription per name and scope (BpmnParse.addEventSubscriptionDeclaration), so the deployment fails', + 'Operaton keeps one signal subscription per name and scope, so the deployment fails', escalation: - 'Operaton keeps one escalation catch per code and scope (BpmnParse.addEscalationEventDefinition), so the deployment fails', + 'Operaton keeps one escalation catch per code and scope, so the deployment fails', cancel: - 'Operaton keeps one cancel handler per attempt block (BpmnParse.parseBoundaryCancelEventDefinition), so the deployment fails', + 'Operaton keeps one cancel handler per attempt block, 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 engine refuses nothing here and takes the first match after sorting its handlers, so BPMNscript keeps one handler per code', }; /** @@ -4738,12 +5295,9 @@ function catchKindOf(handler: OnHandler): string { return 'boundary'; } -function scopeNameOf(scope: AstNode): string | undefined { - return isProcess(scope) || isNamedStatement(scope) ? scope.name : undefined; -} - function scopePhrase(scope: AstNode): string { - const name = scopeNameOf(scope); + const name = + isProcess(scope) || isNamedStatement(scope) ? scope.name : undefined; return name === undefined ? 'in this scope' : `on scope '${name}'`; } @@ -4755,11 +5309,18 @@ function handlerDuplicateMessage(handler: OnHandler, scope: AstNode): string { : 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]}.`; + return diagnosticText( + `another 'on ${handler.trigger}' handler already catches ${caught} ${scopePhrase(scope)}`, + `note: ${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.`; + return diagnosticText( + `an 'on escalation' handler with no code sits beside one with a code ${scopePhrase(scope)}`, + 'note: the code-less one would catch every escalation, and Operaton refuses the pair', + 'help: give both a code, or keep one', + ); } function checkEmptyCode( @@ -4807,27 +5368,37 @@ export function isVariableUse(ref: VarRef): boolean { } function isPriorityValue(value: Expr): boolean { - return ( - isIntegerValue(value) || - isQuotedMatching(value, FORM_BOUND_TEXT) || - isVarRef(value) || - isRawExpr(value) - ); + return isIntegerValue(value) || isVarRef(value) || isRawExpr(value); } +/** JUEL arithmetic yields a number, which the engine truncates with intValue(). */ function isRepeatCountValue(value: Expr): boolean { + if (isParen(value)) return isRepeatCountValue(value.inner); return ( isLiteralInt(value) || isQuotedMatching(value, NON_NEGATIVE_INT_TEXT) || + isQuotedTemplate(value) || isVarRef(value) || - isRawExpr(value) + isRawExpr(value) || + isAdditive(value) || + isMultiplicative(value) ); } +/** + * Quoted text holding a template is a composite expression: the engine + * evaluates it to text and reads a count or version from that, so "0${v}" runs. + */ +const isQuotedTemplate = (value: Expr): boolean => + isLiteralString(value) && + EXPRESSION_ANYWHERE.test(value.value) && + !nestsTemplate(value.value); + function isVersionValue(value: Expr): boolean { const text = integerLiteralText(value); return ( isRawExpr(value) || + isQuotedTemplate(value) || (text === undefined ? isQuotedMatching(value, VERSION_TEXT) : VERSION_TEXT.test(text)) @@ -4846,7 +5417,7 @@ function formDefaultShape( const text = value.$cstNode?.text ?? ''; switch (field.type) { case 'number': - return isIntegerValue(value) || isQuotedMatching(value, FORM_BOUND_TEXT) + return isIntegerValue(value) ? undefined : numberDefaultMessage(field.id, text); case 'boolean': @@ -4874,7 +5445,7 @@ function topicRefusalOf( return settings.some((attr) => attr.key === EXTERNAL_BINDING_KEY) ? undefined : topicBindingMessage( - capitalize(rule.description), + rule.description, item, bindingKeysOf(settings, BUSINESS_RULE_BINDING_KEYS), ); @@ -4911,7 +5482,13 @@ function checkAtMostOneBinding( if (written.length > 1) { accept( 'error', - `${subject} declares more than one binding (${written.join(', ')}); exactly one of ${formatWordList(keys)} is allowed.`, + diagnosticText( + `${subject} declares more than one binding: ${formatPlainWordList( + written.map((key) => `'${key}'`), + 'and', + )}`, + `note: exactly one of ${formatWordList(keys)} is allowed`, + ), target, ); } @@ -4919,20 +5496,29 @@ function checkAtMostOneBinding( function checkThrowEmitBinding( stmt: ThrowStatement | EmitStatement, - subject: 'a thrown' | 'an emitted', + { subject, keyword }: RaisePhrasing, accept: ValidationAcceptor, ): void { const written = bindingKeysOf(settingsOf(stmt.items), THROW_BINDING_KEYS); - if (written.length === 0) return; + if (written.length === 0) { + if (stmt.trigger === THROW_BINDING_TRIGGER) { + accept('warning', unboundMessageThrowMessage(stmt, keyword), { + node: stmt, + property: 'trigger', + }); + } + return; + } if (stmt.trigger !== THROW_BINDING_TRIGGER) { for (const attr of settingsOf(stmt.items)) { if (!written.includes(attr.key)) continue; accept( 'error', - `Setting '${attr.key}' is not valid on ${subject} ${stmt.trigger}; ` + - 'an implementation is what makes the engine really send a message, ' + - 'so only a message carries one.', + diagnosticText( + `setting '${attr.key}' is not valid on ${subject} ${stmt.trigger}`, + 'note: an implementation is what makes the engine send a message, so only a message carries one', + ), { node: attr, property: 'key' }, ); } @@ -4942,17 +5528,45 @@ function checkThrowEmitBinding( checkAtMostOneBinding( settingsOf(stmt.items), THROW_BINDING_KEYS, - capitalize(`${subject} ${stmt.trigger}`), + `${subject} ${stmt.trigger}`, { node: stmt, property: 'trigger' }, accept, ); } +/** + * Operaton's message throw behaviour sends only through an implementation; + * without one it just leaves the event. + */ +function unboundMessageThrowMessage( + stmt: ThrowStatement | EmitStatement, + keyword: string, +): string { + const name = stmt.name !== undefined ? ` ${stmt.name}` : ''; + return `'${keyword} message${name}' sends nothing without a ${formatWordList(THROW_BINDING_KEYS)}`; +} + +const RAISE_PHRASING = { + ThrowStatement: { + triggers: THROW_TRIGGERS_SET, + triggerMessage: throwTriggerMessage, + subject: 'a thrown', + keyword: 'throw', + }, + EmitStatement: { + triggers: EMIT_TRIGGERS_SET, + triggerMessage: emitTriggerMessage, + subject: 'an emitted', + keyword: 'emit', + }, +} as const; + +type RaisePhrasing = (typeof RAISE_PHRASING)[keyof typeof RAISE_PHRASING]; + /** 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', - keyword: 'throw' | 'emit', + { subject, keyword }: RaisePhrasing, accept: ValidationAcceptor, ): void { const code = payloadTextOf(stmt.items); @@ -4960,14 +5574,20 @@ function checkThrowEmitCode( if (code !== undefined) { accept( 'error', - 'Compensation undoes completed work: there is nothing to name; ' + - `write '${keyword} compensation'.`, + diagnosticText( + 'compensation takes no name', + 'note: it undoes completed work, so there is nothing to name', + `help: write '${keyword} compensation'`, + ), { node: payloadItemOf(stmt.items)!, property: 'value' }, ); } return; } - const message = `${subject} ${stmt.trigger} names its code: '${keyword} ${stmt.trigger}()'.`; + const message = diagnosticText( + `${subject} ${stmt.trigger} names no code`, + `help: write '${keyword} ${stmt.trigger}()'`, + ); if (code === undefined) { accept('error', message, { node: stmt, property: 'trigger' }); } else if (code.length === 0) { @@ -5042,6 +5662,14 @@ function checkRenderedName( const TEMPLATE_BODIES = /[$#]\{((?:'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|[^'"}])*)\}/g; +/** A JUEL string literal inside a body may spell '${' as text. */ +const nestsTemplate = (text: string): boolean => + [...text.matchAll(TEMPLATE_BODIES)].some(([, body]) => + EXPRESSION_ANYWHERE.test( + body!.replace(/'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g, ''), + ), + ); + const STRING_OR_PROPERTY_READ = /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|\.\s*([A-Za-z_$][\w$]*)/g; @@ -5156,14 +5784,17 @@ export function intoBranchMessage( keyword: 'parallel' | 'await', ): string { const article = keyword === 'await' ? 'an' : 'a'; - 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}'.`; + return diagnosticText( + `'${subject}' jumps into a branch of ${article} '${keyword}' statement from outside that branch`, + `note: a branch's steps run only when the whole '${keyword}' statement is reached, not through an outside '${jump}'`, + ); } 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 " + - "link' of the same name instead." + return diagnosticText( + `${item} has no effect on an emitted link`, + 'note: the engine creates no activity for a link throw, so nothing written on it runs', + "help: put it on the 'await link' of the same name", ); } diff --git a/packages/language/src/bpmn-script-value-converter.ts b/packages/language/src/bpmn-script-value-converter.ts index 610f50a..76d22fc 100644 --- a/packages/language/src/bpmn-script-value-converter.ts +++ b/packages/language/src/bpmn-script-value-converter.ts @@ -1,6 +1,7 @@ /** * Langium unquotes only `STRING`; a verbatim `RAW_TEMPLATE` would reach the - * engine with a stray `\` and gain another each time the printer quotes it. + * engine with a stray `\` and gain another each time the printer quotes it, + * and a `QUOTED_NAME` would carry its backticks into the id. */ import { @@ -11,7 +12,7 @@ import { type ValueType, } from 'langium'; -const RAW_TEMPLATE_RULE_NAME = 'RAW_TEMPLATE'; +import { unquoteName } from './vocabulary.js'; export class BpmnScriptValueConverter extends DefaultValueConverter { protected override runConverter( @@ -19,9 +20,9 @@ export class BpmnScriptValueConverter extends DefaultValueConverter { input: string, cstNode: CstNode, ): ValueType { - if (rule.name === RAW_TEMPLATE_RULE_NAME) { + if (rule.name === 'RAW_TEMPLATE') return ValueConverter.convertString(input); - } + if (rule.name === 'QUOTED_NAME') return unquoteName(input); return super.runConverter(rule, input, cstNode); } } diff --git a/packages/language/src/bpmn-script.langium b/packages/language/src/bpmn-script.langium index a7f602d..b417f50 100644 --- a/packages/language/src/bpmn-script.langium +++ b/packages/language/src/bpmn-script.langium @@ -3,7 +3,7 @@ // structured statements and there is no `gateway` keyword. // // The AST property `name` is the cross-reference key, which is what lets -// `goto target=[Statement:ID]` resolve through Langium's default NameProvider; +// `goto target=[Statement:Name]` resolve through Langium's default NameProvider; // the BPMN label is the separate `label` setting. // // Reserved words are exactly this file's keywords (`reservedWordsOf` in @@ -23,7 +23,7 @@ entry Model: // Declarations go into `decls` and statements into `body`, so the desugarer // and the validator address them apart. Process: - 'process' name=ID SettingsParens? '{' + 'process' name=Name SettingsParens? '{' (decls+=ProcessDecl)* body+=Statement* '}'; @@ -31,6 +31,14 @@ Process: ProcessDecl: VarDecl | CodeDecl; +// A name becomes an element id, which the engine and other tools key on, so +// any id has to be writable as it stands: one the plain `ID` cannot spell (a +// keyword, a dot, a trailing hyphen) is written in backticks. Both forms carry +// the bare text, so a plain name and the same text in backticks are one name +// to the linker and the validator. +Name returns string: + ID | QUOTED_NAME; + // `VarType` is aligned with Operaton's variable types. VarDecl: 'var' name=ID ':' type=VarType; @@ -38,7 +46,7 @@ VarDecl: VarType returns string: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'any'; -// Every concrete subtype that is a jump target exposes `name=ID`. +// Every concrete subtype that is a jump target exposes `name=Name`. Statement: StartEvent | EndEvent | UserTask | ServiceTask | ScriptTask @@ -52,42 +60,42 @@ 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, and the validator rejects it elsewhere. `name=ID` is +// start carries one, and the validator rejects it elsewhere. `name=Name` 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?; + 'start' name=Name (trigger=ID)? SettingsParens? AttributeBlock?; // 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?; + 'end' name=Name (trigger=ID)? SettingsParens? AttributeBlock?; UserTask: - 'user' name=ID RepeatClause? SettingsParens? AttributeBlock?; + 'user' name=Name RepeatClause? SettingsParens? AttributeBlock?; // One shape for every execution form; the validator requires exactly one // binding attribute. ServiceTask: - 'service' name=ID RepeatClause? SettingsParens? AttributeBlock?; + 'service' name=Name RepeatClause? SettingsParens? AttributeBlock?; GenericTask: - 'step' name=ID RepeatClause? SettingsParens? AttributeBlock?; + 'step' name=Name RepeatClause? SettingsParens? AttributeBlock?; SendTask: - 'send' name=ID RepeatClause? SettingsParens? AttributeBlock?; + 'send' name=Name RepeatClause? SettingsParens? AttributeBlock?; ReceiveTask: - 'receive' name=ID RepeatClause? SettingsParens? AttributeBlock?; + 'receive' name=Name RepeatClause? SettingsParens? AttributeBlock?; BusinessRuleTask: - 'decide' name=ID RepeatClause? SettingsParens? AttributeBlock?; + 'decide' name=Name RepeatClause? SettingsParens? AttributeBlock?; // 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; + 'script' name=Name RepeatClause? SettingsParens? AttributeBlock? body=FENCED_SCRIPT; // 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 @@ -104,7 +112,7 @@ fragment AttributeBlock: // 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; + trigger=ID code=[CodeDecl:Name] when=ID condition=Expr; // `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 @@ -116,7 +124,7 @@ fragment RepeatClause: | 'each' (element=ID)? 'in' collection=Expr ) (sequential?='sequentially')? - ('until' '(' completion=Expr ')')?; + ('until' '(' completion=Expr (',' completionItems+=ParenItem)* ')')?; // A list on the owning element, so a second block is flaggable. FormBlock: @@ -169,8 +177,11 @@ ParenKey returns string: FlagWord returns string: 'alongside' | 'sequentially' | 'local'; +// The name is a key the engine takes as any string, so a keyword or other +// text the plain form cannot spell goes in backticks. Not `Name`, so a missing +// name reports against this rule rather than a code declaration's. IoParameter: - direction=ID name=ID '=' value=IoValue; + direction=ID name=(ID | QUOTED_NAME) '=' value=IoValue; // `[` 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. @@ -211,36 +222,44 @@ Listener: ListenerEvent returns string: ID | 'start' | 'end'; +// What follows a condition inside its own parentheses documents that +// condition, so the parentheses after them stay the gateway's. No expression +// holds a top-level comma, so the first one ends the condition. +fragment ConditionItems: + (',' conditionItems+=ParenItem)*; + // 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. +// takes none. `else fail` writes no default flow, so the engine stops the run +// when no condition holds; `fail` is soft, so `var fail: boolean` stays legal. IfStatement: - 'if' '(' condition=Expr ')' SettingsParens? then=Block + 'if' '(' condition=Expr ConditionItems ')' SettingsParens? then=Block (elseIfs+=ElseIf)* - ('else' elseBlock=Block)?; + ('else' (elseBlock=Block | fail=ID))?; ElseIf: - 'else' 'if' '(' condition=Expr ')' body=Block; + 'else' 'if' '(' condition=Expr ConditionItems ')' body=Block; // 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; + 'while' '(' condition=Expr ConditionItems ')' SettingsParens? body=Block; DoWhileStatement: - 'do' body=Block 'while' '(' condition=Expr ')' SettingsParens?; + 'do' body=Block 'while' '(' condition=Expr ConditionItems ')' SettingsParens?; // 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. +// the join's; a branch head takes none. A closing `else fail` is the OR +// fork's form of the `if` one. ParallelStatement: - 'parallel' SettingsParens? '{' branches+=ParallelBranch (branches+=ParallelBranch)+ '}'; + 'parallel' SettingsParens? '{' branches+=ParallelBranch (branches+=ParallelBranch)+ ('else' fail=ID)? '}'; ParallelBranch: - ('if' '(' condition=Expr ')' | otherwise?='else')? body=Block; + ('if' '(' condition=Expr ConditionItems ')' | otherwise?='else')? body=Block; GotoStatement: - 'goto' target=[Statement:ID]; + 'goto' target=[Statement:Name]; // `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 @@ -248,14 +267,14 @@ GotoStatement: // 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; + (transactional?='attempt' | 'subprocess') name=Name RepeatClause? SettingsParens? AttributeBlock? body=Block; // 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)* '}')?; + 'call' name=Name RepeatClause? SettingsParens? ('{' (mappings+=VariableMapping | params+=IoParameter | listeners+=Listener)* '}')?; // The left side is the receiving name in the receiving scope; omitting // `source` copies a variable under the same name and `all='*'` copies every @@ -270,17 +289,17 @@ VariableMapping: // 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 + 'on' (host=[Statement:Name] ':')? trigger=ID SettingsParens? AttributeBlock? body=Block; // `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?; + 'throw' trigger=ID (name=Name)? SettingsParens? AttributeBlock?; EmitStatement: - 'emit' trigger=ID (name=ID)? SettingsParens? AttributeBlock?; + 'emit' trigger=ID (name=Name)? SettingsParens? AttributeBlock?; // 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 @@ -294,7 +313,7 @@ EmitStatement: AwaitStatement: 'await' ( {infer IntermediateCatchEvent} - trigger=ID (name=ID)? SettingsParens? AttributeBlock? + trigger=ID (name=Name)? SettingsParens? AttributeBlock? | {infer RaceStatement} SettingsParens? '{' branches+=RaceBranch (branches+=RaceBranch)+ '}' ); @@ -310,7 +329,7 @@ RaceBranch: // 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?; + kind=ID name=Name SettingsParens?; // 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 @@ -382,7 +401,7 @@ LiteralNull: // resolves it only in a code position, and `BpmnScriptDocumentValidator` drops // the link failure everywhere else. VarRef: - ref=[CodeDecl:ID] (accessors+=Accessor)*; + ref=[CodeDecl:Name] (accessors+=Accessor)*; Accessor: '.' prop=ID | '[' index=Expr ']'; @@ -420,5 +439,10 @@ terminal RAW_TEMPLATE: /"[$#]\{(\\.|[^"\\])*"|'[$#]\{(\\.|[^'\\])*'/; terminal STRING: /"(\\.|[^"\\])*"|'(\\.|[^'\\])*'/; +// After FENCED_SCRIPT, and never empty, so a fence's opening backticks cannot +// lex as a name. A backslash takes the next character literally, which is how +// a backtick or a backslash gets in; a name never spans lines. +terminal QUOTED_NAME: /`(\\.|[^`\\\r\n])+`/; + hidden terminal ML_COMMENT: /\/\*[\s\S]*?\*\//; hidden terminal SL_COMMENT: /\/\/[^\n\r]*/; diff --git a/packages/language/src/expression-render.ts b/packages/language/src/expression-render.ts index 1df79b6..95ebbcd 100644 --- a/packages/language/src/expression-render.ts +++ b/packages/language/src/expression-render.ts @@ -3,8 +3,9 @@ * dependency runs transform -> language. */ -import type { Expr, Accessor } from './generated/ast.js'; import { + type Accessor, + type Expr, isAdditive, isEquality, isLiteralBool, diff --git a/packages/language/src/paren-items.ts b/packages/language/src/paren-items.ts index 02e75a8..50847a7 100644 --- a/packages/language/src/paren-items.ts +++ b/packages/language/src/paren-items.ts @@ -7,6 +7,7 @@ import { namesACode, TIMER_PARTICLE_BY_KIND, type TimerParticle, + VALUE_DOCUMENTATION_KEYS, } from './vocabulary.js'; import type { AstNode } from 'langium'; import { @@ -59,15 +60,28 @@ export function caughtBindingsOf(items: ParenItem[]): CaughtBinding[] { })); } -/** Catch bindings on a handler and timer keys on a timer; unknown keys anywhere else. */ +/** + * Catch bindings on a handler, timer keys on a timer, and the payload's own + * documentation key on a timer or condition; unknown keys anywhere else. + */ export function isStructuralParenKey(owner: AstNode, key: string): boolean { if (isOnHandler(owner) && EVENT_BINDING_FIELD_SET.has(key)) return true; + const trigger = triggerWordOf(owner); + if (trigger !== undefined && key === payloadDocumentationKeyOf(trigger)) { + return true; + } return ( 'trigger' in owner && TIMER_PARTICLE_KEYS.some((particle) => particle === key) ); } +export function payloadDocumentationKeyOf(trigger: string): string | undefined { + return trigger === 'timer' || trigger === 'condition' + ? VALUE_DOCUMENTATION_KEYS[trigger] + : undefined; +} + const TIMER_PARTICLE_KEYS: readonly TimerParticle[] = [ TIMER_PARTICLE_BY_KIND.date, TIMER_PARTICLE_BY_KIND.cycle, diff --git a/packages/language/src/variable-symbol-provider.ts b/packages/language/src/variable-symbol-provider.ts index 30b80f9..9477c1f 100644 --- a/packages/language/src/variable-symbol-provider.ts +++ b/packages/language/src/variable-symbol-provider.ts @@ -1,7 +1,15 @@ /** 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'; +import { + isIoParameter, + isOnHandler, + isStartEvent, + isUserTask, + isVarDecl, + type Process, + type VarType, +} from './generated/ast.js'; import { caughtBindingsOf } from './paren-items.js'; import { FIELD_DIRECTION, @@ -9,13 +17,6 @@ import { LOOP_VARIABLES, PROPERTY_DIRECTION, } from './vocabulary.js'; -import { - isIoParameter, - isOnHandler, - isVarDecl, - isStartEvent, - isUserTask, -} from './generated/ast.js'; export interface VariableSymbol { name: string; @@ -51,74 +52,72 @@ export function isRepeated(node: AstNode): node is AstNode & RepeatSlots { ); } -/** - * In precedence order: `var`, form field, catch binding, then parameters and - * repeat elements in source order. - */ -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, - }); +export class DefaultVariableSymbolProvider implements VariableSymbolProvider { + /** + * In precedence order: `var`, form field, catch binding, then parameters and + * repeat elements in source order. + */ + 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, + }); + } } - } - 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) { - formFields.push({ - node: field, - property: 'id', - name: field.id, - type, - }); + 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) { + formFields.push({ + node: field, + property: 'id', + name: field.id, + type, + }); + } } } + } else if (isOnHandler(node)) { + for (const { variable, node: setting } of caughtBindingsOf( + node.items, + )) { + if (variable === undefined) continue; + catchBindings.push({ + node: setting.value, + property: 'ref', + name: variable, + type: 'string', + }); + } } - } else if (isOnHandler(node)) { - for (const { variable, node: setting } of caughtBindingsOf(node.items)) { - if (variable === undefined) continue; - catchBindings.push({ - node: setting.value, - property: 'ref', - name: variable, - type: 'string', + 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' }); + } + } else if (isRepeated(node) && node.element !== undefined) { + mapped.push({ + node, + property: 'element', + name: node.element, + 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' }); - } - } 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); + return [...declared, ...formFields, ...catchBindings, ...mapped]; } collect(process: Process): VariableTable { diff --git a/packages/language/src/vocabulary.ts b/packages/language/src/vocabulary.ts index a7b131d..5d5f2ac 100644 --- a/packages/language/src/vocabulary.ts +++ b/packages/language/src/vocabulary.ts @@ -33,13 +33,12 @@ const RESERVED_WORDS_BY_GRAMMAR = new WeakMap>(); export function reservedWordsOf(grammar: Grammar): ReadonlySet { let words = RESERVED_WORDS_BY_GRAMMAR.get(grammar); if (!words) { - const found = new Set(); - for (const node of AstUtils.streamAllContents(grammar)) { - if (GrammarAST.isKeyword(node) && /^[A-Za-z_]/.test(node.value)) { - found.add(node.value); - } - } - words = found; + words = new Set( + AstUtils.streamAllContents(grammar) + .filter(GrammarAST.isKeyword) + .map((keyword) => keyword.value) + .filter((value) => /^[A-Za-z_]/.test(value)), + ); RESERVED_WORDS_BY_GRAMMAR.set(grammar, words); } return words; @@ -58,6 +57,17 @@ export function formatWordList(words: readonly string[]): string { return formatPlainWordList(words.map((w) => `'${w}'`)); } +/** + * A diagnostic: a headline, then `note: ...` and `help: ...` lines, one fact + * each. The wording rules are in CONTRIBUTING.md under "Diagnostic messages". + */ +export function diagnosticText( + headline: string, + ...details: ReadonlyArray +): string { + return [headline, ...details.filter((line) => !!line)].join('\n'); +} + /** The flags that create a continuation job. */ export const ASYNC_FLAG_KEYS = ['asyncBefore', 'asyncAfter'] as const; @@ -130,7 +140,7 @@ export const BOOLEAN_SETTING_KEYS: ReadonlySet = new Set( */ export const RUN_ENGINE_KEYS: readonly string[] = ENGINE_KEYS.filter( (key) => key !== 'jobPriority', -).map((key) => runSettingKey(key)); +).map(runSettingKey); /** `parseServiceTaskLike` dispatches on this before any code attribute. */ export const TYPE_BINDING_KEY = 'type'; @@ -265,7 +275,23 @@ export const CALL_BINDING_VALUES = ['latest', 'deployment'] as const; export type CallBindingValue = (typeof CALL_BINDING_VALUES)[number]; -const LABEL_KEYS = ['label', 'documentation'] as const; +/** Always the element's own documentation, never a value's. */ +export const DOCUMENTATION_KEY = 'documentation'; + +/** + * Documents a value, wherever it is written. Each key is named for the word + * that introduces the value: a timer, any condition (`if`, `while`, `until`, + * the `condition` trigger), the count and the `each` variable of a `for` + * clause. + */ +export const VALUE_DOCUMENTATION_KEYS = { + timer: 'timerDocumentation', + condition: 'conditionDocumentation', + count: 'countDocumentation', + each: 'eachDocumentation', +} as const; + +const LABEL_KEYS = ['label', DOCUMENTATION_KEY] as const; export const PROCESS_ENGINE_HEADER_KEYS = [ 'versionTag', @@ -295,15 +321,17 @@ export const IO_DIRECTIONS: readonly string[] = [ OUTPUT_DIRECTION, ]; +export const USER_TASK_DUE_DATE_KEYS = ['dueDate', 'followUpDate'] as const; +export const USER_TASK_PRIORITY_KEY = 'priority'; + /** 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', + ...USER_TASK_DUE_DATE_KEYS, + USER_TASK_PRIORITY_KEY, ] as const; /** Set once on the bound implementation; reads and writes no process variable. */ @@ -327,6 +355,12 @@ export const EXTERNAL_TASK_EL_NAME = 'externalTask'; export const ERROR_MAPPING_HEAD = 'error'; export const ERROR_MAPPING_WHEN = 'when'; +/** + * `else fail` closes a split whose every branch is conditioned with no + * fallback; the TextMate grammar spells it too (`textmate-postprocess.mjs`). + */ +export const ELSE_FAIL_WORD = 'fail'; + export type TimerKind = 'duration' | 'date' | 'cycle'; /** Keyed by the timer definition each selects (`timeDuration`, `timeDate`, `timeCycle`). */ @@ -444,15 +478,42 @@ export const FORM_CONSTRAINT_TYPES: Readonly< */ 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', - )!, -); +function terminalNamed(name: string): RegExp { + return GrammarUtils.terminalRegex( + BpmnScriptGrammar().rules.find( + (rule): rule is GrammarAST.TerminalRule => + GrammarAST.isTerminalRule(rule) && rule.name === name, + )!, + ); +} + +export const ID_TERMINAL: RegExp = terminalNamed('ID'); + +export const QUOTED_NAME_TERMINAL: RegExp = terminalNamed('QUOTED_NAME'); export const ID_TEXT = new RegExp(`^${ID_TERMINAL.source}$`, ID_TERMINAL.flags); +/** Lexes as `ID` rather than a keyword, so a `Name` needs no backticks. */ +export function isPlainName(text: string): boolean { + return ID_TEXT.test(text) && !reservedWordsOf(BpmnScriptGrammar()).has(text); +} + +/** The `QUOTED_NAME` escape: a backslash takes the next character literally. */ +export function printName(text: string): string { + return isPlainName(text) ? text : `\`${text.replace(/[`\\]/g, '\\$&')}\``; +} + +export function unquoteName(quoted: string): string { + return quoted.slice(1, -1).replace(/\\(.)/g, '$1'); +} + +/** + * An id both ends accept: moddle drops an element whose id is not ASCII + * letters, digits, `_`, `-` and `.` after a letter or `_`, and Operaton + * validates against `BPMN20.xsd`, whose `xsd:ID` admits no `:`. + */ +export const ENGINE_ID_TEXT = /^[A-Za-z_][\w.-]*$/; + /** * `StringUtil.isExpression`: an opening at the start only. An opening further * in is a constant, which `BpmnParse.parsePriority` refuses. @@ -767,7 +828,13 @@ function withKeys( keys: new Set([ ...ENGINE_KEYS, ...rule.own, - ...(rule.repeats ? RUN_ENGINE_KEYS : []), + ...(rule.repeats + ? [ + ...RUN_ENGINE_KEYS, + VALUE_DOCUMENTATION_KEYS.count, + VALUE_DOCUMENTATION_KEYS.each, + ] + : []), ]), }; } diff --git a/packages/language/test/completion.test.ts b/packages/language/test/completion.test.ts index fef9463..7f9e306 100644 --- a/packages/language/test/completion.test.ts +++ b/packages/language/test/completion.test.ts @@ -17,6 +17,7 @@ import { FORM_CONSTRAINT_TYPES, FORM_FIELD_TYPES, TRIGGER_PAYLOAD, + VALUE_DOCUMENTATION_KEYS, } from '@bpmn-script/language'; import { BLOCK_HOSTS, caretInSlot } from './helpers/block-hosts.js'; import { withTextMessages } from './helpers/diagnostics.js'; @@ -162,6 +163,12 @@ const TIMER_KEYS: Item[] = [ const START: Item = ['start', CONSTRUCT, 'start ${1:name}']; +const ELSE_FAIL: Item = [ + 'fail', + 'stop the run with an error when no condition holds', + 'fail', +]; + const STATEMENTS: Item[] = [ START, ['end', CONSTRUCT, 'end ${1:name}'], @@ -404,7 +411,15 @@ const SERVICE_PARENS: Item[] = [ ...ENGINE_SETTINGS, ]; -const REPEATED_SERVICE_PARENS: Item[] = [...SERVICE_PARENS, ...RUN_SETTINGS]; +const REPEATED_SERVICE_PARENS: Item[] = [ + ...SERVICE_PARENS, + ...RUN_SETTINGS, + [ + VALUE_DOCUMENTATION_KEYS.count, + SETTING, + `${VALUE_DOCUMENTATION_KEYS.count}: "\${1:documentation}"`, + ], +]; const CALL_PARENS: Item[] = [ LABEL, @@ -471,6 +486,13 @@ const DECLARATION_FIELDS: Item[] = [ ['message', SETTING, 'message: "${1:message}"'], ]; +const DECLARED_ERROR: Item = [ + 'PAYMENT_DECLINED', + 'CodeDecl', + 'PAYMENT_DECLINED', + CompletionItemKind.Reference, +]; + const SETTING_VALUE = 'BPMNscript setting value'; const TYPE_VALUES: Item[] = [ @@ -647,11 +669,11 @@ describe('the completions offered at a caret', () => { // `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 }', + "the host position offers the container's activities, one in backticks as written, and nothing from another scope", + 'process p {\n user Review\n service `Ship.1`\n subprocess Sub {\n user Inner\n }\n on |\n}\nprocess q { user Decoy }', [ ['Review', 'UserTask', 'Review', CompletionItemKind.Reference], - ['Ship', 'ServiceTask', 'Ship', CompletionItemKind.Reference], + ['`Ship.1`', 'ServiceTask', '`Ship.1`', CompletionItemKind.Reference], ['Sub', 'SubProcess', 'Sub', CompletionItemKind.Reference], ...ON_TRIGGERS, ], @@ -855,6 +877,16 @@ describe('the completions offered at a caret', () => { ['any', KEYWORD, 'any'], ], ], + [ + "the word after an 'if' chain's 'else' offers 'fail' beside 'else if'", + 'process p {\n var a: boolean\n if (a) {\n user A\n } else |\n}', + [ELSE_FAIL], + ], + [ + "the word after a 'parallel' block's last branch offers 'fail' after 'else'", + 'process p {\n var a: boolean\n parallel {\n if (a) {\n user A\n }\n if (!a) {\n user B\n }\n else |\n }\n}', + [ELSE_FAIL], + ], // The scope keeps the declared codes out of an expression's offer. [ 'an expression position offers no code declaration, only the literal words', @@ -864,52 +896,24 @@ describe('the completions offered at a caret', () => { [ 'a code position offers the declared codes of its own kind and nothing else', 'process p {\n error PAYMENT_DECLINED(message: "x")\n escalation PAYMENT_REVIEW\n throw error(PAY|)\n}', - [ - [ - 'PAYMENT_DECLINED', - 'CodeDecl', - 'PAYMENT_DECLINED', - CompletionItemKind.Reference, - ], - ], + [DECLARED_ERROR], ], [ "a mapping's code slot offers the declared errors and nothing else", 'process p {\n error PAYMENT_DECLINED(message: "x")\n escalation PAYMENT_REVIEW\n service S(topic: "t") {\n error PAY| when "${x}"\n }\n}', - [ - [ - 'PAYMENT_DECLINED', - 'CodeDecl', - 'PAYMENT_DECLINED', - CompletionItemKind.Reference, - ], - ], + [DECLARED_ERROR], ], // 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, - ], - ], + [DECLARED_ERROR], ], // 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, - ], - ], + [DECLARED_ERROR], ], [ 'a class-bound service block offers no code at the head word, since it maps nothing', @@ -1312,7 +1316,7 @@ describe('offered is a subset of accepted', () => { '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]], + [...ENGINE_KEYS, VALUE_DOCUMENTATION_KEYS.timer, ALONGSIDE[0]], ], [ 'a condition handler offers the boolean words at its open payload', @@ -1324,7 +1328,7 @@ describe('offered is a subset of accepted', () => { '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]], + [...ENGINE_KEYS, VALUE_DOCUMENTATION_KEYS.condition, ALONGSIDE[0]], ], [ 'a message handler offers nothing before its name is written', @@ -1364,6 +1368,47 @@ describe('offered is a subset of accepted', () => { `process p {\n var a: boolean\n if (a) (${x}) {\n user A\n }\n}`, labels(SPLIT_AND_JOIN_PARENS), ], + [ + "an if condition's parentheses offer its documentation key alone", + (x) => + `process p {\n var a: boolean\n if (a, ${x}) {\n user A\n }\n}`, + [VALUE_DOCUMENTATION_KEYS.condition], + ], + [ + 'a partly typed key after an if condition still offers its documentation key', + (x) => + `process p {\n var a: boolean\n if (a, ${x === '|' ? 'd|' : x}) {\n user A\n }\n}`, + [VALUE_DOCUMENTATION_KEYS.condition], + ], + [ + "a while condition's parentheses offer its documentation key alone", + (x) => + `process p {\n var a: boolean\n while (a, ${x}) {\n user A\n }\n}`, + [VALUE_DOCUMENTATION_KEYS.condition], + ], + [ + "a do-while condition's parentheses offer its documentation key alone", + (x) => + `process p {\n var a: boolean\n do {\n user A\n } while (a, ${x})\n}`, + [VALUE_DOCUMENTATION_KEYS.condition], + ], + [ + "a parallel branch condition's parentheses offer its documentation key alone", + (x) => + `process p {\n var a: boolean\n parallel {\n if (a, ${x}) {\n user A\n }\n else {\n user B\n }\n }\n}`, + [VALUE_DOCUMENTATION_KEYS.condition], + ], + [ + "a completion condition's parentheses offer its documentation key alone", + (x) => `process p {\n var a: boolean\n user R for 3 until (a, ${x})\n}`, + [VALUE_DOCUMENTATION_KEYS.condition], + ], + [ + 'a written condition documentation is not offered again', + (x) => + `process p {\n var a: boolean\n if (a, conditionDocumentation: "d", ${x}) {\n user A\n }\n}`, + [], + ], [ 'a version pin on a user task offers its two words', (x) => `process p {\n user T(formRef: "f", binding: ${x})\n}`, diff --git a/packages/language/test/helpers/block-hosts.ts b/packages/language/test/helpers/block-hosts.ts index f673f9a..da29640 100644 --- a/packages/language/test/helpers/block-hosts.ts +++ b/packages/language/test/helpers/block-hosts.ts @@ -109,8 +109,10 @@ export const BLOCK_HOSTS: ReadonlyArray< [ 'throw', 'a throw statement', - (c) => `process p { start S throw message("Settled") { ${c} } }`, - (i) => `process p { start S throw message("Settled", ${i}) }`, + (c) => + `process p { start S throw message("Settled", class: "com.example.Send") { ${c} } }`, + (i) => + `process p { start S throw message("Settled", class: "com.example.Send", ${i}) }`, ], [ 'emit', diff --git a/packages/language/test/helpers/diagnostics.ts b/packages/language/test/helpers/diagnostics.ts index d3f9ade..92e927e 100644 --- a/packages/language/test/helpers/diagnostics.ts +++ b/packages/language/test/helpers/diagnostics.ts @@ -14,10 +14,9 @@ export function withTextMessages( } 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 ' + - 'ends or redirects the flow before reaching it, so this step would lower ' + - 'to a disconnected node with no incoming flow, which is invalid BPMN.'; + 'this step can never run\n' + + "note: an earlier 'end', 'throw', 'goto', 'emit link', or an 'if', 'parallel', or 'await' whose every branch ends always ends or redirects the flow before it\n" + + 'note: the step would have no incoming flow, which is invalid BPMN'; export const undeclaredVariable = (name: string): string => - `Variable '${name}' is not declared. Add 'var ${name}: ' to the process.`; + `variable '${name}' is not declared\nhelp: add 'var ${name}: ' to the process`; diff --git a/packages/language/test/hover.test.ts b/packages/language/test/hover.test.ts index a7e532a..ed9d6c1 100644 --- a/packages/language/test/hover.test.ts +++ b/packages/language/test/hover.test.ts @@ -27,6 +27,11 @@ const ROWS: Row[] = [ '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 name in backticks shows the bare id its goto resolves to', + 'process p {\n user `Task.1`\n goto <|>`Task.1`\n}', + "a user task 'Task.1'", + ], [ 'a subprocess names itself a subprocess', 'process p {\n subprocess <|>Pack(label: "Pack the goods") { }\n}', diff --git a/packages/language/test/navigation.test.ts b/packages/language/test/navigation.test.ts index 0cd2bd6..a8c5996 100644 --- a/packages/language/test/navigation.test.ts +++ b/packages/language/test/navigation.test.ts @@ -140,6 +140,30 @@ const RENAMES: RenameRow[] = [ 'Declined', 'process p {\n error Declined\n service S(topic: "t") { error Declined when true }\n throw error(Declined)\n on error(Declined) { end Failed }\n}', ], + [ + 'a step renamed to an id the plain form cannot spell is written in backticks at every site', + 'process p {\n user Review\n goto <|>Review\n on Review: timer("PT1H") { end Late }\n}', + 'Task.1', + 'process p {\n user `Task.1`\n goto `Task.1`\n on `Task.1`: timer("PT1H") { end Late }\n}', + ], + [ + 'a name in backticks renamed at its plain twin edits both spellings', + 'process p {\n user `Review`\n goto <|>Review\n}', + 'Check', + 'process p {\n user Check\n goto Check\n}', + ], + [ + 'a new name typed in backticks is not quoted twice', + 'process p {\n user <|>`Task.1`\n goto Task\n user Task\n}', + '`process`', + 'process p {\n user `process`\n goto Task\n user Task\n}', + ], + [ + 'a code renamed to text with a space is written in backticks in the throw', + 'process p {\n error E\n throw error(<|>E)\n}', + 'Payment failed', + 'process p {\n error `Payment failed`\n throw error(`Payment failed`)\n}', + ], ]; type Row = readonly [title: string, text: string]; @@ -173,6 +197,10 @@ const DEFINITIONS: Row[] = [ "a host goes to the step's name", 'process p {\n user <|Review|>\n on <|>Review: timer("PT1H") { end Late }\n}', ], + [ + 'a plain goto goes to its twin in backticks', + 'process p {\n user <|`Review`|>\n goto <|>Review\n}', + ], [ "a thrown code goes to the declaration's name", 'process p {\n error <|E|>\n throw error(<|>E)\n}', diff --git a/packages/language/test/parsing.test.ts b/packages/language/test/parsing.test.ts index 3fae659..41ec093 100644 --- a/packages/language/test/parsing.test.ts +++ b/packages/language/test/parsing.test.ts @@ -20,6 +20,7 @@ import type { } from '@bpmn-script/language'; import { createBpmnScriptServices, + printName, renderExpression, settingsOf, } from '@bpmn-script/language'; @@ -171,6 +172,8 @@ describe('Parsing - control flow and containers', () => { 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 } } + if (a) { user A } else if (b) { user B } else fail + parallel { if (a) { user A } if (b) { user B } else fail } }`, [ 'UserTask(name="A"), UserTask(name="B"), UserTask(name="C")', @@ -181,6 +184,8 @@ describe('Parsing - control flow and containers', () => { '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")]))])', + 'IfStatement(condition=VarRef(ref=->a), then=Block(statements=[UserTask(name="A")]), elseIfs=[ElseIf(condition=VarRef(ref=->b), body=Block(statements=[UserTask(name="B")]))], fail="fail")', + 'ParallelStatement(branches=[ParallelBranch(condition=VarRef(ref=->a), body=Block(statements=[UserTask(name="A")])), ParallelBranch(condition=VarRef(ref=->b), body=Block(statements=[UserTask(name="B")]))], fail="fail")', ], ], [ @@ -425,6 +430,36 @@ describe('Parsing - soft words stay plain identifiers', () => { }); }); +describe('Parsing - names in backticks', () => { + test.each([ + ['a keyword is written in backticks', 'process', '`process`'], + ['a dot is written in backticks', 'Task.1', '`Task.1`'], + ['a trailing hyphen is written in backticks', 'Review-', '`Review-`'], + ['a leading digit is written in backticks', '1st', '`1st`'], + ['a space is written in backticks', 'Payment failed', '`Payment failed`'], + ['a backtick and a backslash take a backslash', 'a`b\\c', '`a\\`b\\\\c`'], + ['a plain name stays plain', 'invoice-approval', 'invoice-approval'], + ])('%s', async (_title, text, printed) => { + expect(printName(text)).toBe(printed); + const model = await parseModel(`process p { error ${printed} }`); + expect(model.processes[0]!.decls.map((d) => d.name)).toEqual([text]); + }); + + test('a name in backticks and its plain twin are one name to every reference', async () => { + await expectProcess( + 'process `process` { start `S` goto S on S: timer("PT1H") { end `end` } }', + 'Process(name="process", body=[StartEvent(name="S"), GotoStatement(target=->S), OnHandler(host=->S, trigger="timer", items=[ParenValue(value=LiteralString(value="PT1H"))], body=Block(statements=[EndEvent(name="end")]))])', + ); + }); + + test('a fence is never read as a name', async () => { + await expectProcess( + 'process p { script S ```js\nx = `a`\n``` }', + 'Process(name="p", body=[ScriptTask(name="S", body="```js\\nx = `a`\\n```")])', + ); + }); +}); + describe('Parsing - expressions', () => { test.each([ [ @@ -790,83 +825,93 @@ describe('Parsing - repeat clause', () => { ); }); -/** Chevrotain's list-of-alternatives wording, which carries no guidance. */ -const STOCK_ALTERNATIVES = ''; +const reservedWordAsName = (word: string) => + `'${word}' is a reserved word and cannot be a plain name here\n` + + `help: to use '${word}' as the name, write it in backticks as '\`${word}\`'`; 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}}".`; + `'${word}' is a reserved word and cannot be a plain name here\n` + + `help: to refer to a variable named '${word}', write the quoted raw expression "\${${word}}"`; + +const STEP_KEYWORD_NOTE = + "note: every step starts with a keyword such as 'start', 'user', 'service', 'if', 'on', 'throw', or 'emit'"; const notAStepKeyword = (word: string) => - `'${word}' is neither a known declaration nor a step keyword. ` + - "A declaration starting with a plain word is 'error' or 'escalation' " + - 'followed by the name it declares; every step starts ' + - "with a keyword such as 'start', 'user', 'service', 'if', 'on', 'throw', 'emit', ..."; + `'${word}' is neither a known declaration nor a step keyword\n` + + "note: a declaration starting with a plain word is 'error' or 'escalation' followed by the name it declares\n" + + STEP_KEYWORD_NOTE; 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', ..."; + `'${word}' is not a step keyword\n${STEP_KEYWORD_NOTE}`; const notATypeWord = (word: string) => - `'${word}' is not a word this position takes; write 'string', 'number', ` + - `'boolean', 'date', 'json', or 'any'.`; + `'${word}' is not a word this position takes\n` + + "help: write 'string', 'number', 'boolean', 'date', 'json', or 'any'"; + +const UNCLOSED_BLOCK = + "expected '}' before the end of the file\nnote: a block is still open"; const REPEAT_CLAUSE_GUIDANCE = - "A repeat clause ('for ...') attaches to the step that repeats. " + - 'The statement before it does not take one; move the clause onto ' + - 'the step that should.'; + "a repeat clause ('for ...') follows a statement that does not take one\n" + + 'note: a repeat clause attaches to the step that repeats\n' + + 'help: move the clause onto the step that should repeat'; const VAR_PLACEMENT_GUIDANCE = - "A variable declaration ('var ...') must come before the first step in " + - 'the process, with the other declarations. Move it above the first ' + - 'statement.'; + "a variable declaration ('var ...') comes after the first step\n" + + 'note: declarations come before the first step of the process\n' + + 'help: move it above the first statement'; describe('Parsing - sources the parser rejects', () => { test.each([ [ 'parallel requires at least two branches', `process p { parallel { { user A } } }`, - [STOCK_ALTERNATIVES, 'Expecting end of file but found `}`.'], + [ + "expected 'if', '{', or 'else' after '}'", + "unexpected '}' outside any process", + ], ], // The `else if` row blames `if`: with the parens breaking the `else if` - // shape, the lookahead settles on `else` opening a plain block. + // shape, the lookahead settles on `else` taking a block or `fail`. [ '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`.", - "Expected '}' before the end of the file: a block is still open.", - ], + ["expected '{' or 'fail' after 'else'"], + ], + [ + "a keyword after 'else' other than 'if' is no name to fix", + `process p { var a: boolean if (a) { user A } else user B }`, + ["expected '{' or 'fail' after 'else'"], ], [ 'a parallel branch head takes no settings: the parallel head governs the fork and the join', `process p { parallel { if (a) (asyncBefore: true) { user A } { user B } } }`, - ["Expecting token of type '{' but found `(`."], + ["expected '{' after ')'"], ], [ 'a race requires at least two branches', `process p { await { message("M") { user U } } }`, - [STOCK_ALTERNATIVES, 'Expecting end of file but found `}`.'], + ["expected a name after '}'", "unexpected '}' outside any process"], ], [ '`attempt` is reserved: it does not name a step', `process p { user attempt }`, - [reservedWord('attempt'), "Expecting token of type 'ID' but found `}`."], + [reservedWordAsName('attempt'), "expected a name after 'attempt'"], ], [ '`attempt` is reserved: it does not name a variable', `process p { var attempt: string }`, - [reservedWord('attempt'), "Expecting token of type 'ID' but found `:`."], + [reservedWord('attempt'), "expected a name after 'attempt'"], ], [ '`await` is reserved: it does not name a variable', `process p { var await: string }`, - [reservedWord('await'), STOCK_ALTERNATIVES], + [reservedWord('await'), "expected a name, '(', or '{' after 'await'"], ], [ 'a colon with no trigger after it is rejected', `process p { user Pack on Pack: { } }`, - ["Expecting token of type 'ID' but found `{`."], + ["expected a name after ':'"], ], ...(['in', 'out', 'local', 'alongside'] as const).map( (word) => @@ -876,27 +921,68 @@ describe('Parsing - sources the parser rejects', () => { [reservedWord(word)], ] as const, ), - ...(['call', 'on', 'throw', 'emit'] as const).map( + [ + // The call's member block takes the braces of the `if` body. + '`call` is rejected as a bare identifier in expression position', + `process p { if (call > 2) { user A } }`, + [ + reservedWord('call'), + "expected a name after 'call'", + "expected '}' after '{'", + "unexpected '}' outside any process", + ], + ], + ...(['on', 'throw', 'emit'] as const).map( (word) => [ `\`${word}\` is rejected as a bare identifier in expression position`, `process p { if (${word} > 2) { user A } }`, [ reservedWord(word), - "Expecting token of type 'ID' but found `>`.", - 'Expecting end of file but found `}`.', + `expected a name after '${word}'`, + "unexpected '}' outside any process", ], ] as const, ), [ 'a trailing comma in a list is rejected', `process p { service S { input a = [1, ] } }`, - [STOCK_ALTERNATIVES], + ["expected a value after ','"], ], [ 'a trailing comma in a map is rejected', `process p { service S { input a = { k: 1, } } }`, - [STOCK_ALTERNATIVES], + ["expected a name or a quoted string after ','"], + ], + [ + 'an operator with no right operand expects a value after it', + `process p { if (c && ) { user A } }`, + ["expected a value after '&&'"], + ], + [ + 'a step keyword with no name expects a name after the keyword', + `process p { user }`, + ["expected a name after 'user'"], + ], + [ + 'a stray token after the last process is outside any process', + `process p { user A } }`, + ["unexpected '}' outside any process"], + ], + [ + 'text that opens no process is outside any process', + `garbage`, + ["unexpected 'garbage' outside any process"], + ], + [ + 'an unclosed brace says a block is still open', + `process p { user A`, + [UNCLOSED_BLOCK], + ], + [ + 'a character the language has no token for is named', + `process p { user A @ }`, + ["unexpected character '@'"], ], [ 'a mistyped statement keyword gets the declaration-or-step guidance', @@ -912,9 +998,9 @@ describe('Parsing - sources the parser rejects', () => { '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', ...", + "expected '=' after 'A'\n" + + "note: inside a block, two plain words start a parameter such as 'input name = value'\n" + + STEP_KEYWORD_NOTE, ], ], [ @@ -923,24 +1009,24 @@ describe('Parsing - sources the parser rejects', () => { [stepKeywordOnly('usr')], ], [ - 'a parameter direction missing its name keeps the stock message, since the word is not a mistyped step', + 'a parameter direction missing its name expects a name, since the word is not a mistyped step', `process p { if (a) { user B input } }`, - ["Expecting token of type 'ID' but found `}`."], + ["expected a name after 'input'"], ], [ 'a missing ) names the token it stopped at', `process p { if (a { user A } }`, - ["Expected ')' before '{': a '(' is still open."], + ["expected ')' before '{'\nnote: 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."], + ["expected ')' before the end of the file\nnote: a '(' is still open"], ], [ 'a declaration kind followed by text blames the name slot, not the kind word', `process p { error "PF" }`, - ['Expecting token of type \'ID\' but found `"PF"`.'], + ["expected a name after 'error'"], ], [ 'a var declaration after the first step gets placement guidance', @@ -973,151 +1059,195 @@ describe('Parsing - sources the parser rejects', () => { [ 'a reserved word in the type slot of a var declaration names the types it takes', 'process p {\n var amount: while\n start S\n}', - [notATypeWord('while'), "Expecting token of type '(' but found `start`."], + [notATypeWord('while'), "expected '(' after 'while'"], ], [ '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}', [ reservedWord('while'), - "Expecting token of type '(' but found `}`.", - "Expecting token of type 'EOF' but found `}`.", + "expected '(' after 'while'", + "unexpected '}' outside any process", ], ], [ 'an ordinary word in the type slot gets the same guidance', 'process p {\n var amount: text\n start S\n}', - [notATypeWord('text'), reservedWord('start')], + [notATypeWord('text'), reservedWordAsName('start')], ], [ - 'a quoted string in the type slot keeps the stock message', + 'a quoted string in the type slot is named as unexpected', 'process p {\n var amount: "text"\n start S\n}', - [STOCK_ALTERNATIVES], + [`unexpected '"text"' after ':'`], ], ])('%s', async (_title, source, expected) => { - const messages = (await parseErrors(source)).map((message) => - /possible Token sequences/.test(message) - ? STOCK_ALTERNATIVES - : message.replace(/\n\s*/g, ' '), - ); - expect(messages).toEqual(expected); + expect(await parseErrors(source)).toEqual(expected); }); }); -type LabelCarrier = readonly [keyword: string, head: string, tail: string]; +type LabelCarrier = readonly [ + keyword: string, + head: string, + tail: string, + refusal: string, +]; + +const closeAfter = (name: string) => `expected '}' after '${name}'`; const LABEL_CARRIERS: readonly LabelCarrier[] = [ - ['process', 'process p', '{ start S }'], - ['start', 'process p { start S', '}'], - ['end', 'process p { end E', '}'], - ['user', 'process p { user X', '}'], - ['service', 'process p { service X', '}'], - ['step', 'process p { step X', '}'], - ['send', 'process p { send X', '}'], - ['receive', 'process p { receive X', '}'], - ['decide', 'process p { decide X', '}'], - ['script', 'process p { script X', `${FENCE}js\nwork()\n${FENCE} }`], - ['subprocess', 'process p { subprocess S', '{ user U } }'], - ['call', 'process p { call C', '}'], + ['process', 'process p', '{ start S }', "expected '{' after 'p'"], + ['start', 'process p { start S', '}', closeAfter('S')], + ['end', 'process p { end E', '}', closeAfter('E')], + ['user', 'process p { user X', '}', closeAfter('X')], + ['service', 'process p { service X', '}', closeAfter('X')], + ['step', 'process p { step X', '}', closeAfter('X')], + ['send', 'process p { send X', '}', closeAfter('X')], + ['receive', 'process p { receive X', '}', closeAfter('X')], + ['decide', 'process p { decide X', '}', closeAfter('X')], + [ + 'script', + 'process p { script X', + `${FENCE}js\nwork()\n${FENCE} }`, + "expected a fenced script after 'X'", + ], + [ + 'subprocess', + 'process p { subprocess S', + '{ user U } }', + "expected '{' after 'S'", + ], + ['call', 'process p { call C', '}', closeAfter('C')], ]; describe('Parsing - a slot has one spelling', () => { - test.each([ + test.each< + readonly [ + title: string, + refused: string, + taken: string, + refusal: readonly string[], + ] + >([ ...LABEL_CARRIERS.map( - ([keyword, head, tail]) => + ([keyword, head, tail, refusal]) => [ `a \`${keyword}\` label is a setting, not a string after the name`, `${head} "L" ${tail}`, `${head}(label: "L") ${tail}`, + [refusal], ] as const, ), [ 'an element setting is written in the parens, not in the block', `process p { user U { assignee = "demo" } }`, `process p { user U(assignee: "demo") }`, + [ + "expected 'form', a name, or 'on' after '{'", + "unexpected '}' outside any process", + ], ], [ 'a call setting is written in the parens, not in the block', `process p { call C { process = "q" } }`, `process p { call C(process: "q") }`, + ["expected '}' after '{'", "expected a name after 'process'"], ], [ 'a listener binding is written in the parens, not in a block of its own', `process p { user U { on create { class = "com.acme.L" } } }`, `process p { user U { on create(class: "com.acme.L") } }`, + [stepKeywordOnly('class')], ], [ 'a process label is a setting on the head, not a header declaration', `process p { label = "L" start S }`, `process p(label: "L") { start S }`, + [notAStepKeyword('label')], ], [ 'a process setting is written on the head, not as a header declaration', `process p { versionTag = "1.4" start S }`, `process p(versionTag: "1.4") { start S }`, + [notAStepKeyword('versionTag')], ], [ "an error's message rides its declaration, not a declaration of its own", `process p { error "PF" message "Payment failed" start S }`, `process p { error PF(message: "Payment failed") start S }`, + ["expected a name after 'error'", notAStepKeyword('message')], ], [ 'a start names its message in the parens', `process p { start S message "M" }`, `process p { start S message("M") }`, + ["expected '}' after 'message'"], ], [ 'a start times its timer in the parens', `process p { start S timer after "PT1H" }`, `process p { start S timer("PT1H") }`, + [stepKeywordOnly('after')], ], [ 'an end carries whatever it names in the parens', `process p { end E terminate "X" }`, `process p { end E terminate("X") }`, + ["expected '}' after 'terminate'"], ], [ 'a handler names its code in the parens', `process p { on error "X" { user A } }`, `process p { on error("X") { user A } }`, + ["expected '{' after 'error'"], ], [ 'a handler times its timer in the parens', `process p { on timer after "PT1H" { user A } }`, `process p { on timer("PT1H") { user A } }`, + ["expected '{' after 'timer'", stepKeywordOnly('after')], ], [ 'a handler marks itself non-interrupting with a flag in the parens', `process p { on signal("S") alongside { user A } }`, `process p { on signal("S", alongside) { user A } }`, + ["expected '{' after ')'"], ], [ 'a throw names its code in the parens', `process p { throw error "C" }`, `process p { throw error("C") }`, + ["expected '}' after 'error'"], ], [ 'an emit names its code in the parens', `process p { emit signal "S" }`, `process p { emit signal("S") }`, + ["expected '}' after 'signal'"], ], [ 'an awaited event names its message in the parens', `process p { await message "M" }`, `process p { await message("M") }`, + ["expected '}' after 'message'"], ], [ 'an awaited event times its timer in the parens', `process p { await timer after "PT1H" }`, `process p { await timer("PT1H") }`, + ["expected '}' after 'after'"], ], [ 'a race branch heads on the same parens a plain await does', `process p { await { message "M" { user A } timer after "PT1H" { user B } } }`, `process p { await { message("M") { user A } timer("PT1H") { user B } } }`, + [ + "expected '{' after 'message'", + "expected '{' after 'timer'", + stepKeywordOnly('after'), + ], ], - ])('%s', async (_title, refused, taken) => { - expect(await parseErrors(refused)).not.toEqual([]); + ])('%s', async (_title, refused, taken, refusal) => { + expect(await parseErrors(refused)).toEqual(refusal); expect(await parseErrors(taken)).toEqual([]); }); }); diff --git a/packages/language/test/recovery-fuzz.test.ts b/packages/language/test/recovery-fuzz.test.ts index 2e77a42..77163ca 100644 --- a/packages/language/test/recovery-fuzz.test.ts +++ b/packages/language/test/recovery-fuzz.test.ts @@ -9,6 +9,7 @@ import { validationHelper, type ValidationResult } from 'langium/test'; import type { Model } from '@bpmn-script/language'; import { createBpmnScriptServices } from '@bpmn-script/language'; +import { FENCE } from './helpers/block-hosts.js'; import { withTextMessages } from './helpers/diagnostics.js'; const SEED = 0x5eed; @@ -17,8 +18,6 @@ const MUTANTS = 2000; /** Only catches a runaway: a mutant costs about 0.5ms warm, fifteen times that cold. */ const TIMEOUT_MS = MUTANTS * 30; -const FENCE = '`' + '`' + '`'; - /** * 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 diff --git a/packages/language/test/scoping.test.ts b/packages/language/test/scoping.test.ts index a39f602..06d767f 100644 --- a/packages/language/test/scoping.test.ts +++ b/packages/language/test/scoping.test.ts @@ -126,10 +126,11 @@ async function checkCodeRow( } const BOUNDARY_RULE = { - subprocess: 'a goto cannot cross a subprocess boundary.', + subprocess: 'note: a goto cannot cross a subprocess boundary', handler: - "a goto cannot cross an event handler boundary: an event handler's steps run only when its event fires.", - host: 'a boundary event attaches to an activity in its own scope.', + 'note: a goto cannot cross an event handler boundary\n' + + "note: an event handler's steps run only when its event fires", + host: 'note: a boundary event attaches to an activity in its own scope', } as const; function boundary( @@ -137,19 +138,20 @@ function boundary( where: string, rule: keyof typeof BOUNDARY_RULE, ): string { - return `error: '${name}' is ${where}; ${BOUNDARY_RULE[rule]}`; + return `error: '${name}' is ${where}\n${BOUNDARY_RULE[rule]}`; } function missingStep(name: string): string { - return `error: No step named '${name}' in this process.`; + 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.`; + return `error: no step named '${name}' in this process to attach to`; } const MULTI_PROCESS = - 'error: Only one process is supported per file. Move additional processes into separate files.'; + 'error: only one process is supported per file\n' + + 'help: move additional processes into separate files'; describe('Scoping - process-scoped goto', () => { test.each([ @@ -163,7 +165,8 @@ describe('Scoping - process-scoped goto', () => { `process p { parallel { { user A } { user B } } goto A }`, [ 'goto A -> p/A:UserTask', - "error: 'goto A' jumps into a branch of a 'parallel' statement from outside that branch; a branch's steps run only when the whole 'parallel' statement is reached, not via an external 'goto'.", + "error: 'goto A' jumps into a branch of a 'parallel' statement from outside that branch\n" + + "note: a branch's steps run only when the whole 'parallel' statement is reached, not through an outside 'goto'", ], ], [ @@ -478,7 +481,7 @@ describe('Scoping - code declarations reached from a code position', () => { `process p { user Pack throw error(NOPE) }`, [ 'NOPE -> unresolved', - `error: 'NOPE' is not declared. Add 'error NOPE' to the process.`, + `error: 'NOPE' is not declared\nhelp: add 'error NOPE' to the process`, ], ], [ @@ -486,7 +489,7 @@ describe('Scoping - code declarations reached from a code position', () => { `process p { user Pack emit escalation(NOPE) }`, [ 'NOPE -> unresolved', - `error: 'NOPE' is not declared. Add 'escalation NOPE' to the process.`, + `error: 'NOPE' is not declared\nhelp: add 'escalation NOPE' to the process`, ], ], [ @@ -494,7 +497,7 @@ describe('Scoping - code declarations reached from a code position', () => { `process p { error CODE(message: "m") user A emit escalation(CODE) user B }`, [ 'CODE -> unresolved', - `error: 'CODE' is declared as an error, not an escalation.`, + `error: 'CODE' is declared as an error, not an escalation`, ], ], [ @@ -502,7 +505,7 @@ describe('Scoping - code declarations reached from a code position', () => { `process a { error X(message: "x") user U } process b { user V throw error(X) }`, [ 'X -> unresolved', - `error: 'X' is not declared. Add 'error X' to the process.`, + `error: 'X' is not declared\nhelp: add 'error X' to the process`, MULTI_PROCESS, ], ], @@ -540,7 +543,7 @@ describe('Scoping - the code slot of an error mapping', () => { `process p { service V(topic: "t") { error NOPE when "\${x}" } }`, [ 'error NOPE -> unresolved', - `error: 'NOPE' is not declared. Add 'error NOPE' to the process.`, + `error: 'NOPE' is not declared\nhelp: add 'error NOPE' to the process`, ], ], [ @@ -548,7 +551,7 @@ describe('Scoping - the code slot of an error mapping', () => { `process p { escalation E service V(topic: "t") { error E when "\${x}" } }`, [ 'error E -> unresolved', - `error: 'E' is declared as an escalation, not an error.`, + `error: 'E' is declared as an escalation, not an error`, ], ], [ @@ -556,8 +559,8 @@ describe('Scoping - the code slot of an error mapping', () => { `process p { escalation E service V(topic: "t") { escalation E when "\${x}" } }`, [ 'escalation E -> p/E:CodeDecl', - 'error: An external task maps a reported failure onto an error and ' + - "nothing else; write 'error when '.", + 'error: an external task maps a reported failure onto an error only\n' + + "help: write 'error when '", ], ], ])('%s', checkCodeRow); diff --git a/packages/language/test/semantic-tokens.test.ts b/packages/language/test/semantic-tokens.test.ts index 718f1d1..cc72b98 100644 --- a/packages/language/test/semantic-tokens.test.ts +++ b/packages/language/test/semantic-tokens.test.ts @@ -40,9 +40,16 @@ const SOFT_WORDS_AS_VAR_NAME = [ 'compensation', 'after', 'terminate', + 'fail', ]; -const SOFT_WORDS_AS_OPERAND = ['priority', 'code', 'compensation', 'after']; +const SOFT_WORDS_AS_OPERAND = [ + 'priority', + 'code', + 'compensation', + 'after', + 'fail', +]; describe('Semantic tokens - soft event words', () => { test.each([ @@ -123,6 +130,13 @@ describe('Semantic tokens - soft event words', () => { KEYWORD, ], ], + [ + "'fail' after 'else' highlights as a keyword on both heads", + `var a: boolean + if (a) { user A } else <|fail|> + parallel { if (a) { user B } if (!a) { user C } else <|fail|> }`, + [KEYWORD, KEYWORD], + ], [ 'a soft word as a variable name carries no token, declared or read', [ diff --git a/packages/language/test/symbols.test.ts b/packages/language/test/symbols.test.ts index cd6476e..1bd7d2c 100644 --- a/packages/language/test/symbols.test.ts +++ b/packages/language/test/symbols.test.ts @@ -64,6 +64,10 @@ const EXPECTED = [ ' Event StrikeRecorded', ]; +const symbols = expectSymbols( + createBpmnScriptServices(EmptyFileSystem).BpmnScript, +); + describe('document outline', () => { test('every element kinded, every handler named by its header and holding its steps', async () => { const text = readFileSync( @@ -73,12 +77,24 @@ describe('document outline', () => { ), 'utf8', ); - const symbols = expectSymbols( - createBpmnScriptServices(EmptyFileSystem).BpmnScript, - ); await symbols({ text, assert: (result) => expect(flatten(result, 0)).toEqual(EXPECTED), }); }); + + test('a variable and a code constant are kinded by their declaration, not by the declared type', async () => { + await symbols({ + text: 'process P { var total: number var tag: string error BAD start S end E }', + assert: (result) => + expect(flatten(result, 0)).toEqual([ + 'Module P', + ' Variable total', + ' Variable tag', + ' Constant BAD', + ' Event S', + ' Event E', + ]), + }); + }); }); diff --git a/packages/language/test/validating.test.ts b/packages/language/test/validating.test.ts index 7edaa67..d047dfa 100644 --- a/packages/language/test/validating.test.ts +++ b/packages/language/test/validating.test.ts @@ -62,6 +62,7 @@ import { flagFalseMessage, flagNotTrueMessage, FORM_KEY_AND_REF_MESSAGE, + formatPlainWordList, FORM_NEVER_OFFERED_MESSAGE, FORM_REF_BINDING_MESSAGE, FORM_REF_MISSING_MESSAGE, @@ -93,6 +94,7 @@ import { missingBuiltinFieldMessage, NESTED_START_FORM_MESSAGE, NESTED_START_INITIATOR_MESSAGE, + NESTED_TEMPLATE_MESSAGE, noDefaultStartMessage, noFieldHostMessage, noJobMessage, @@ -102,6 +104,10 @@ import { noPropertyHostMessage, numberDefaultMessage, onTriggerMessage, + ELSE_FAIL_BESIDE_ELSE_MESSAGE, + ELSE_FAIL_BESIDE_UNCONDITIONED_MESSAGE, + ELSE_FAIL_WITHOUT_CONDITION_MESSAGE, + elseFailWordMessage, PARALLEL_ELSE_BESIDE_UNCONDITIONED_MESSAGE, PARALLEL_ELSE_WITHOUT_CONDITION_MESSAGE, PARALLEL_SECOND_ELSE_MESSAGE, @@ -160,6 +166,7 @@ import { PARAMETER_HOSTS, } from './helpers/block-hosts.js'; import { + type TextDiagnostic, UNREACHABLE, undeclaredVariable as undeclared, withTextMessages, @@ -172,16 +179,31 @@ beforeAll(() => { validate = validationHelper(services.BpmnScript); }); +const textOf = (d: TextDiagnostic): string => + d.severity === DiagnosticSeverity.Warning + ? `warning: ${d.message}` + : d.message; + async function diagnosticsOf(source: string): Promise { const { diagnostics } = await validate(source); - return withTextMessages(diagnostics).map((d) => - d.severity === DiagnosticSeverity.Warning - ? `warning: ${d.message}` - : d.message, - ); + return withTextMessages(diagnostics).map(textOf); } +async function placedDiagnosticsOf(source: string): Promise { + const { diagnostics } = await validate(source); + return withTextMessages(diagnostics).map((d) => [d.range, textOf(d)]); +} + +const rangeOf = (source: string, text: string) => ({ + start: { line: 0, character: source.indexOf(text) }, + end: { line: 0, character: source.indexOf(text) + text.length }, +}); + const warn = (message: string) => `warning: ${message}`; +const unboundSend = (statement: string) => + warn( + `'${statement}' sends nothing without a 'class', 'expression', 'delegate', or 'topic'`, + ); type Case = [title: string, source: string, expected: string[]]; @@ -193,8 +215,6 @@ function checks(concern: string, cases: Case[]): void { }); } -const capitalized = (text: string) => text[0]!.toUpperCase() + text.slice(1); - /** One form, a field `f` per row; an enum gets a value to keep its empty-values warning out. */ function formFieldRows( concern: string, @@ -239,102 +259,113 @@ const typeMismatch = ( context: string, op: string, ) => - `Variable '${name}' of type '${type}' cannot be used in ${context} (operator '${op}').`; + `variable '${name}' of type '${type}' cannot be used in ${context} with '${op}'`; const ONE_PROCESS_ONLY = - 'Only one process is supported per file. ' + - 'Move additional processes into separate files.'; + 'only one process is supported per file\n' + + 'help: move additional processes into separate files'; 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).`; + `process '${name}' has no flow steps\n` + + 'note: a process needs at least one step on its main flow, and handlers alone do not start it'; 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).`; + `${kind} named '${name}' has no flow steps\n` + + `note: ${kind} needs at least one step on its main flow, and handlers alone do not start it`; const RESERVED_SHAPES = `'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 the desugarer or the ` + - 'layouter generates.'; + `statement name '${name}' matches a reserved id pattern\n` + + `note: the shapes ${RESERVED_SHAPES} are reserved for ids the compiler 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.`; + `statement name '${name}' is the id generated for the implicit ${role} event of '${container}'\n` + + 'help: choose another name'; const duplicateVariable = (name: string, process: string) => - `Variable '${name}' is already declared in process '${process}'.`; + `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.`; + `step name '${name}' is already used by another step in process '${process}'\n` + + `note: '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.`; + `step name '${name}' equals the process id\n` + + 'note: 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.`; + `catch-binding variable '${name}' is typed 'string', but '${name}' is already declared as '${prior}'`; 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.`; + `form field '${id}' is typed '${type}', but '${id}' is already declared as '${prior}'`; -const duplicateSetting = (key: string) => `Duplicate setting '${key}'.`; +const duplicateSetting = (key: string) => `duplicate setting '${key}'`; const notValidOn = (key: string, description: string) => - `Setting '${key}' is not valid on ${description}.`; + `setting '${key}' is not valid on ${description}`; const flagNotValidOn = (flag: string, description: string) => - `Flag '${flag}' is not valid on ${description}.`; + `flag '${flag}' is not valid on ${description}`; const bindingNotAName = (field: string) => - `A catch binding names the variable the caught ${field} lands in, not a ` + - `value: write '${field}: '.`; + 'this catch binding names a value instead of a variable\n' + + `note: a catch binding names the variable the caught ${field} lands in\n` + + `help: write '${field}: '`; const barewordName = (trigger: string, text: string) => - `A ${trigger} name is the text the engine matches by name, not a declared ` + - `name. Write '${trigger}("${text}")'.`; -const quotedBoolean = (key: string) => - `Setting '${key}' takes an unquoted boolean; ` + - `write '${key}: true' or '${key}: false'.`; + `a ${trigger} name is the text the engine matches by name, not a declared name\n` + + `help: write '${trigger}("${text}")'`; +const quotedBoolean = (key: string) => booleanShapeMessage(key); const unquotedText = (key: string) => - `Setting '${key}' takes a quoted string or a "\${...}" expression; ` + - 'put the value in quotes.'; + `setting '${key}' is not a quoted string or a "\${...}" expression\n` + + 'help: put the value in quotes'; const SERVICE_BINDINGS = `'class', 'expression', 'delegate', 'topic', or 'type'`; const DECISION_BINDINGS = `'class', 'expression', 'delegate', 'topic', 'type', or 'decision'`; /** No `type`: a thrown message has no member block for a built-in behaviour's fields. */ const THROW_BINDINGS = `'class', 'expression', 'delegate', or 'topic'`; const LISTENER_BINDINGS = `'class', 'expression', or 'delegate'`; const emptyBinding = (key: string, noun: string) => - `Setting '${key}' cannot be empty; name the ${noun}.`; + `setting '${key}' is empty\nhelp: name the ${noun}`; const bindingRequired = (subject: string, keys: string, alternative = '') => - `${subject} must declare a ${keys} setting${alternative}.`; + `${subject} has no ${keys} setting${alternative}`; const bindingConflict = (subject: string, written: string, keys: string) => - `${subject} declares more than one binding (${written}); exactly one of ${keys} is allowed.`; + `${subject} declares more than one binding: ${formatPlainWordList( + written.split(', ').map((key) => `'${key}'`), + 'and', + )}\n` + `note: exactly one of ${keys} is allowed`; const MAP_DECISION_RESULT = - `Setting 'mapDecisionResult' must be 'singleEntry', 'singleResult', ` + - `'collectEntries', or 'resultList'.`; + "setting 'mapDecisionResult' must be 'singleEntry', 'singleResult', 'collectEntries', or 'resultList'"; const noFormBlock = (description: string) => - `${capitalized(description)} cannot declare a 'form' block; forms belong on start events and user tasks.`; + `${description} cannot declare a 'form' block\n` + + 'note: forms belong on start events and user tasks'; const oneFormBlock = (description: string) => - `${description} may declare at most one 'form' block.`; -const duplicateFormField = (id: string) => `Duplicate form field '${id}'.`; + `${description} has a second 'form' block\n` + + "note: it may declare one 'form' block at most"; +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.`; + `form field '${id}' has type '${type}', which a form cannot use\n` + + 'help: use string, number, boolean, date, or enum'; const noParameters = (description: string) => - `${capitalized(description)} cannot declare an 'input' or 'output' parameter; ${PARAMETER_HOSTS_MESSAGE}`; + `${description} cannot declare an 'input' or 'output' parameter\n${PARAMETER_HOSTS_MESSAGE}`; const duplicateParameter = (direction: string, name: string) => - `Duplicate '${direction}' parameter '${name}'.`; + `duplicate '${direction}' parameter '${name}'`; const EMPTY_MAP_KEY = - "A map entry's key cannot be empty; name the key its value is looked up by."; + 'this map entry has an empty key\n' + + 'help: name the key its value is looked up by'; const taskListenerOnly = (event: string, description: string) => - `'on ${event}' is a task listener, which only a user task has; ` + - `${description} takes 'start' or 'end'.`; + `'on ${event}' is a task listener, which only a user task has\n` + + `note: ${description} takes 'start' or 'end'`; const unknownListenerEvent = (event: string, events: string) => - `Unknown listener event '${event}'; write ${events}.`; + `unknown listener event '${event}'\nhelp: write ${events}`; const USER_LISTENER_EVENTS = `'start', 'end', 'create', 'assignment', 'complete', 'update', 'delete', or 'timeout'`; -const LISTENER_PARTICLE_ONLY = "Only 'on timeout' takes a particle."; +const LISTENER_PARTICLE_ONLY = "only 'on timeout' takes a particle"; const codeNotDeclared = (trigger: string, name: string) => - `'${name}' is not declared. Add '${trigger} ${name}' to the process.`; + `'${name}' is not declared\nhelp: add '${trigger} ${name}' to the process`; const unsupportedScriptTag = (subject: string, tag: string) => - `${subject} has an unsupported language tag '${tag}'. ` + - "Use 'juel', 'js', 'javascript', 'ecmascript', 'groovy', 'py', 'python', " + - "'rb', 'ruby', or 'feel'."; -const emptyScript = (subject: string) => `${subject} has an empty script body.`; + `${subject} has an unsupported language tag '${tag}'\n` + + "help: use 'juel', 'js', 'javascript', 'ecmascript', 'groovy', 'py', 'python', " + + "'rb', 'ruby', or 'feel'"; +const emptyScript = (subject: string) => + warn( + `${subject} has an empty script body\nnote: the engine runs it and it does nothing`, + ); const unterminatedScript = (name: string) => - `Script task '${name}' has a malformed or unterminated fenced script body; ` + - 'a script must be a closed ``` ... ``` block.'; + `script task '${name}' has a malformed or unterminated fenced script body\n` + + 'note: a script is a closed ``` ... ``` block'; /** `ScriptingEngines.getScriptEngineForLanguage` lowercases the tag, so a mixed-case alias resolves. */ const SCRIPT_TAG_MATRIX: ReadonlyArray< @@ -357,43 +388,50 @@ function scriptTagCases( ]); } -const emptyBranch = (what: string) => `The ${what} has no steps.`; +const emptyBranch = (what: string) => `the ${what} has no steps`; const emptyNumberedBranch = (index: number, keyword: string) => - `Branch ${index} of the '${keyword}' statement has no steps.`; + `branch ${index} of the '${keyword}' statement has no steps`; const blockParameter = (direction: string) => - blockMemberMessage(`An '${direction}' parameter`, 'configures'); -const BLOCK_LISTENER = blockMemberMessage('A listener', 'observes'); + blockMemberMessage(`an '${direction}' parameter`, 'configures'); +const BLOCK_LISTENER = blockMemberMessage('a listener', 'observes'); const gotoIntoBranch = (target: string, keyword: 'parallel' | 'await') => 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.`; + `nothing named '${name}' is in reach here`; +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.`; + `no step named '${name}' in this process to attach to`; +const reservedWordAsName = (word: string) => + `'${word}' is a reserved word and cannot be a plain name here\n` + + `help: to use '${word}' as the name, write it in backticks as '\`${word}\`'`; 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}}".`; + `'${word}' is a reserved word and cannot be a plain name here\n` + + `help: to refer to a variable named '${word}', write the quoted raw expression "\${${word}}"`; const CALL_PROCESS_REQUIRED = - 'A call must name the process it starts: add process: "".'; -const BINDING_VALUE = `Setting 'binding' must be 'latest' or 'deployment'.`; -const BINDING_IS_VERSION = `Write 'version: ' instead of 'binding: version'.`; + "this call has no 'process' setting naming the process it starts\n" + + 'help: add \'process: ""\''; +const BINDING_VALUE = "setting 'binding' must be 'latest' or 'deployment'"; +const BINDING_IS_VERSION = + "'binding: version' is not a binding mode\n" + + "help: write '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.`; + `${subject} combines 'binding' and 'version'\n` + + "help: write 'version: ' to pin a specific version, or 'binding: latest' or 'binding: deployment' for the other modes"; const duplicateMapping = (direction: string, target: string) => - `Duplicate '${direction}' mapping target '${target}'.`; + `duplicate '${direction}' mapping target '${target}'`; const duplicateAllMapping = (direction: string) => - `Duplicate '${direction} *' mapping; a direction can copy every variable only once.`; + `duplicate '${direction} *' mapping\n` + + 'note: a direction can copy every variable only once'; 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. ' + - 'A start event cannot have incoming flows.'; + `'start ${name}' is neither a top-level statement nor the first statement of its block\n` + + 'note: a start event cannot have incoming flows\n' + + 'note: a start belongs at the top level of its process, or first in its subprocess, attempt block, or event-handler body'; 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."; + `the statement before 'start ${name}' still flows on to it\n` + + 'note: a start opens an entry of its own and takes no incoming flow\n' + + "help: close that flow first with 'end', 'throw', or 'goto', or move the start ahead of that step"; const NESTED_START_CONTAINERS: ReadonlyArray< [kind: string, wrap: (body: string) => string] > = [ @@ -405,112 +443,177 @@ const NESTED_START_CONTAINERS: ReadonlyArray< ], ]; 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."; + `the start of ${kind} cannot carry a trigger\n` + + "note: only the process's own start carries a trigger\n" + + `note: ${kind} is entered from the step before it\n` + + "help: put the trigger on an 'on' handler inside the block if it should react to an event"; const START_CONDITION_REQUIRED = - "A condition start needs its condition: 'start S condition(amount > 100)'."; + 'a condition start has no condition\n' + + "help: write '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)'."; + 'a condition start holds a quoted string instead of a condition\n' + + "help: write the condition itself: 'start S condition(amount > 100)'"; const START_CONDITION_ONLY = - 'Only a condition start takes a condition expression.'; + 'only a condition start takes a condition expression'; const startNameRequired = (trigger: string) => - `A ${trigger} start needs the ${trigger}'s name: the engine matches ${trigger}s by name.`; -const START_PARTICLE_ONLY = 'Only a timer start takes a particle.'; + `a ${trigger} start has no ${trigger} name\n` + + `note: the engine matches ${trigger}s by name`; +const START_PARTICLE_ONLY = 'only a timer start takes a particle'; const unknownParticle = (word: string) => - `Unknown timer particle '${word}'; write 'after', 'at', or 'every'.`; + `unknown timer particle '${word}'\nhelp: write 'after', 'at', or 'every'`; const REPEATING_INTERRUPTS = - 'A repeating timer that interrupts its scope fires at most once: ' + - "add 'alongside' to let it repeat, or give it a duration instead."; + 'a repeating timer that interrupts its scope fires at most once\n' + + "help: add 'alongside' to let it repeat, or give it a duration instead"; 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 ' + - 'whole scope, not for a single branch.'; + 'this event handler sits inside a branch or loop body\n' + + 'note: a handler handles events for a whole scope, not for a single branch\n' + + 'note: it belongs directly in the body of a process, a subprocess, an attempt block, or another event handler'; const HANDLER_TRAILING = - 'Event handlers read like catch blocks: move it after the last step of ' + - 'this body.'; + 'this event handler comes before a step of its body\n' + + 'note: event handlers read like catch blocks\n' + + 'help: move it after the last step of this body'; const nameless = (trigger: string) => - `A ${trigger} handler needs the ${trigger}'s name: the engine matches ${trigger}s by name.`; + `a ${trigger} handler has no ${trigger} name\n` + + `note: the engine matches ${trigger}s by name`; const CONDITION_REQUIRED = - "A condition handler needs its condition: 'on condition(amount > 100)'."; + 'a condition handler has no condition\n' + + "help: write 'on condition(amount > 100)'"; 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 PARTICLE_ONLY = "Only 'on timer' takes a particle."; + 'a condition handler holds a quoted string instead of a condition\n' + + "help: write the condition itself: 'on condition(amount > 100)'"; +const CONDITION_ONLY = "only 'on condition' takes a condition expression"; +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.`; + `a ${trigger} carries no code to bind\n` + + "note: '(code: c)' bindings belong to error and escalation handlers"; const handlerDuplicate = (trigger: string, caught: string, scope: string) => - `Another 'on ${trigger}' handler already catches ${caught} on scope '${scope}': ${HANDLER_DUPLICATE_RULE[trigger]}.`; + `another 'on ${trigger}' handler already catches ${caught} on scope '${scope}'\n` + + `note: ${HANDLER_DUPLICATE_RULE[trigger]}`; const everyEvent = 'every event of this kind'; const code = (value: string) => `code '${value}'`; const eventName = (value: string) => `name '${value}'`; const escalationCatchAllBesideCoded = (scope: string) => - `An 'on escalation' handler with no code cannot sit beside one with a code on scope '${scope}': the code-less one would catch every escalation, and Operaton refuses the pair (BpmnParse.addEscalationEventDefinition). Give both a code, or keep one.`; + `an 'on escalation' handler with no code sits beside one with a code on scope '${scope}'\n` + + 'note: the code-less one would catch every escalation, and Operaton refuses the pair\n' + + 'help: 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, ' + - 'send, or receive task, a step, a decision step, a subprocess, an attempt ' + - `block, or a call; '${name}' is ${kind}.`; + `this boundary event attaches to '${name}', which is ${kind}\n` + + 'note: a boundary event can only attach to an activity: a user, service, script, ' + + 'send, or receive task, a step, a decision step, a subprocess, an attempt block, or a call'; 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}.`; + `this escalation boundary attaches to '${name}', which is ${kind}\n` + + 'note: an escalation boundary can only attach to a subprocess, an attempt block, a call, or a user task'; const throwCompensationNames = (keyword: 'throw' | 'emit') => - 'Compensation undoes completed work: there is nothing to name; ' + - `write '${keyword} compensation'.`; + 'compensation takes no name\n' + + 'note: it undoes completed work, so there is nothing to name\n' + + `help: write '${keyword} compensation'`; 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}.`; + `this cancel handler attaches to '${name}', which is ${kind}\n` + + "note: a cancel handler can only attach to an 'attempt' block, since it catches that block being given up"; const codeRequired = ( - subject: 'A thrown' | 'An emitted', + subject: 'a thrown' | 'an emitted', trigger: string, keyword: 'throw' | 'emit', -) => `${subject} ${trigger} names its code: '${keyword} ${trigger}()'.`; +) => + `${subject} ${trigger} names no code\n` + + `help: write '${keyword} ${trigger}()'`; 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.'; + `setting '${key}' is not valid on ${subject}\n` + + 'note: an implementation is what makes the engine send a message, so only a message carries one'; const awaitNameRequired = (kind: string) => - `An awaited ${kind} needs the ${kind}'s name: the engine matches ${kind}s by name.`; + `an awaited ${kind} has no ${kind} name\n` + + `note: the engine matches ${kind}s by name`; const AWAIT_CONDITION_REQUIRED = - "An awaited condition needs its condition: 'await condition(amount > 100)'."; + 'an awaited condition has no condition\n' + + "help: write 'await condition(amount > 100)'"; const AWAIT_CONDITION_NO_CODE = - "An awaited condition takes no code string; write the condition itself: 'await condition(amount > 100)'."; + 'an awaited condition holds a quoted string instead of a condition\n' + + "help: write the condition itself: 'await condition(amount > 100)'"; const AWAIT_CONDITION_ONLY = - "Only 'await condition' takes a condition expression."; -const AWAIT_PARTICLE_ONLY = "Only 'await timer' takes a particle."; + "only 'await condition' takes a condition expression"; +const AWAIT_PARTICLE_ONLY = "only 'await timer' takes a particle"; 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 ' + - 'flow should continue.'; + `no 'await link("${name}")' catches this link\n` + + 'note: the engine refuses to deploy an emitted link with no catch of its name\n' + + 'help: write one where the flow should continue'; const linkOtherContainer = (name: string) => - `'emit link("${name}")' must sit in the same process, subprocess, or ` + - "handler body as its 'await link': a link cannot cross a subprocess or " + - "handler boundary, the same way a 'goto' cannot."; + `'emit link("${name}")' and its 'await link' sit in different scopes\n` + + "note: a link cannot cross a subprocess or handler boundary, the same way a 'goto' cannot\n" + + 'help: put both in the same process, subprocess, or handler body'; const linkIntoBranch = (name: string, keyword: 'parallel' | 'await') => 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.'; + `another 'await link("${name}")' already catches this link\n` + + 'note: the engine keeps one catch per link name in the whole file, even across subprocesses'; const linkUnused = (name: string) => - `No 'emit link("${name}")' names this catch, so it and the steps after it ` + - 'never run.'; + `no 'emit link("${name}")' names this catch\n` + + 'note: the catch and the steps after it never run'; 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."; + `'goto ${target}' targets an awaited link\n` + + "note: a link catch is entered by 'emit link' of the same name, not by a sequence flow"; const declarationNotAString = (kind: string, key: string) => - `An ${kind} declaration's ${key} must be a quoted string.`; + `the ${key} of an ${kind} declaration is not a quoted string`; const declarationEmpty = (kind: string, key: string) => - `An ${kind} declaration's ${key} cannot be empty.`; + `the ${key} of an ${kind} declaration is empty`; const alreadyDeclared = (kind: string, name: string) => - `'${name}' is already declared in this process; '${kind}(${name})' would be ambiguous.`; + `'${name}' is already declared in this process\n` + + `note: '${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.`; + `${kind} code '${code}' is already declared by '${owner}'\n` + + 'note: two declarations cannot share a code'; + +const notAnEngineId = (name: string) => + `'${name}' is not an id Operaton deploys\n` + + "note: the name becomes the element's id, and Operaton validates every id against 'BPMN20.xsd' as an 'xs:ID'\n" + + "note: an id is ASCII letters, digits, '_', '-' and '.', starting with a letter or '_'"; + +checks('Validation - names in backticks', [ + [ + 'a keyword, a dot and a trailing hyphen in backticks are ids Operaton deploys', + 'process `process` { start `start` user `Task.1` user `Review-` goto `end` end `end` }', + [], + ], + [ + 'text no id may hold is refused at the name', + 'process p { start S user `a b` step `1st` end E }', + [notAnEngineId('a b'), notAnEngineId('1st')], + ], + [ + 'a name in backticks and its twin collide, and the note spells the goto in backticks', + 'process p { start S user `Task.1` step `Task.1` end E }', + [ + "step name 'Task.1' is already used by another step in process 'p'\n" + + "note: 'goto `Task.1`' would be ambiguous", + ], + ], + [ + 'backticks do not free a shape kept for generated ids', + 'process p { start S user `Flow_A.B_C` end E }', + [reservedName('Flow_A.B_C')], + ], + [ + 'a code in backticks is read where a code is read', + 'process p { error `Payment failed`(code: "PF") start S throw error(`Payment failed`) }', + [], + ], + [ + 'a variable in backticks is refused', + 'process p { var x: number start S if (`x` > 1) { end A } end E }', + [ + "'x' in backticks is not a variable\n" + + 'note: backticks spell the name of a step, a process or a code\n' + + `help: to refer to a variable named 'x', write the quoted raw expression "\${x}"`, + ], + ], +]); checks('Validation - variables in expressions', [ [ @@ -708,7 +811,8 @@ checks('Validation - a condition must be boolean', [ 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).", + 'the condition must be boolean, but it is null\n' + + "note: the engine throws 'condition expression returns null' when it evaluates it", nonBooleanCondition('a number'), nonBooleanCondition('an arithmetic expression'), nonBooleanCondition('an arithmetic expression'), @@ -745,6 +849,41 @@ checks('Validation - a composite raw template cannot be an operand', [ ], ]); +checks('Validation - a template cannot nest another', [ + [ + "a '${' inside a template is refused wherever a raw template stands", + `process p { + var c: number + service A(expression: "\${a\${b}}") + user B for "\${1\${c}}" + service S(class: "com.acme.D") { field subject = "\${a} and \${b}" } + if ("\${fn('\${')}") { user C } +}`, + [NESTED_TEMPLATE_MESSAGE, NESTED_TEMPLATE_MESSAGE], + ], +]); + +checks('Validation - quoted parameter names', [ + [ + "a key in backticks is a field or property key, never the variable an 'input' or 'output' sets", + `process p { + service S(class: "a.B") { field \`true\` = "x" } + user U { + form { f: string { property \`true\` = "yes" } } + input \`a b\` = 1 + output \`true\` = 2 + input plain = 3 + } +}`, + ['input', 'output'].map( + (direction, i) => + `'${['a b', 'true'][i]}' in backticks is not a variable\n` + + `note: an '${direction}' parameter names the variable it sets\n` + + 'note: backticks spell a key, or the name of a step, a process or a code', + ), + ], +]); + checks('Validation - binding values', [ [ "a binding value's shape decides whether it resolves as a class, name, or text", @@ -767,14 +906,14 @@ checks('Validation - binding values', [ emptyBinding('decision', 'decision table to evaluate'), emptyBinding('mapper', 'mapping class to load'), emptyBinding('mapperDelegate', 'mapping delegate to resolve'), - literalElBindingMessage('expression'), + warn(literalElBindingMessage('expression')), templateAsClassMessage('class', 'delegate'), ], ], [ 'a quoted expression on a thrown message evaluates to its own text', `process p { start S throw message("Ack", expression: "b") }`, - [literalElBindingMessage('expression')], + [warn(literalElBindingMessage('expression'))], ], ...( [ @@ -894,7 +1033,7 @@ checks('Validation - attribute keys and value shapes', [ }`, [ notValidOn('assignee', 'a service task'), - bindingRequired(`Service task 'S'`, SERVICE_BINDINGS), + bindingRequired(`service task 'S'`, SERVICE_BINDINGS), ], ], ...( @@ -911,6 +1050,13 @@ checks('Validation - attribute keys and value shapes', [ ['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'], + ['class', 'step T(class: "com.example.X")', 'a step'], + ['decision', 'receive R(decision: "d")', 'a receive task'], + [ + 'message', + 'send N(class: "com.example.Send", message: "M")', + 'a send task', + ], ] as const ).map(([key, body, description]): Case => [ `${key} on ${description} is not valid there`, @@ -948,17 +1094,17 @@ checks('Validation - service, send, and decision bindings', [ 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'), + 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', + 'a decision step', 'delegate', 'businessRuleTask', ), - bindingVersionClash('A decision step'), + bindingVersionClash('a decision step'), BINDING_IS_VERSION, MAP_DECISION_RESULT, ], @@ -972,12 +1118,12 @@ checks('Validation - service, send, and decision bindings', [ }`, [ bindingConflict( - `Service task 'S'`, + `service task 'S'`, 'class, expression', SERVICE_BINDINGS, ), - bindingRequired(`Send task 'N'`, SERVICE_BINDINGS), - bindingRequired(`Decision step 'D'`, DECISION_BINDINGS), + bindingRequired(`send task 'N'`, SERVICE_BINDINGS), + bindingRequired(`decision step 'D'`, DECISION_BINDINGS), ], ], [ @@ -987,9 +1133,9 @@ checks('Validation - service, send, and decision bindings', [ decide D(decision: "riskRating", class: "com.example.Rate") }`, [ - bindingConflict(`Send task 'N'`, 'class, topic', SERVICE_BINDINGS), + bindingConflict(`send task 'N'`, 'class, topic', SERVICE_BINDINGS), bindingConflict( - `Decision step 'D'`, + `decision step 'D'`, 'decision, class', DECISION_BINDINGS, ), @@ -1060,7 +1206,7 @@ const VALUE_SHAPES: ValueShape[] = [ }).map(([carrier, at]): ValueShape => [ `version on ${carrier}`, at, - ['2', '"2"', '"${v}"'], + ['2', '"2"', '"${v}"', '"0${v}"'], ['1.5', '"abc"', '-1', '0', 'v'], VERSION_SHAPE_MESSAGE, ]), @@ -1121,6 +1267,7 @@ checks('Validation - the mail and shell bindings', [ }`, [ notValidOn('type', 'an emit statement'), + unboundSend('emit message'), TYPE_VALUE_MESSAGE, TYPE_VALUE_MESSAGE, shellFieldExpressionMessage('command'), @@ -1140,12 +1287,12 @@ checks('Validation - the mail and shell bindings', [ }`, [ missingBuiltinFieldMessage( - `Service task 'N'`, + `service task 'N'`, 'mail', BUILTIN_REQUIRED_FIELDS.mail[0]!, ), missingBuiltinFieldMessage( - `Send task 'S'`, + `send task 'S'`, 'shell', BUILTIN_REQUIRED_FIELDS.shell[0]!, ), @@ -1156,7 +1303,7 @@ checks('Validation - the mail and shell bindings', [ `process p { send N(type: "mail") { field to = "a@b" field subject = "s" } }`, [ missingBuiltinFieldMessage( - `Send task 'N'`, + `send task 'N'`, 'mail', BUILTIN_REQUIRED_FIELDS.mail[1]!, ), @@ -1167,12 +1314,12 @@ checks('Validation - the mail and shell bindings', [ `process p { service N(type: "mail") }`, [ missingBuiltinFieldMessage( - `Service task 'N'`, + `service task 'N'`, 'mail', BUILTIN_REQUIRED_FIELDS.mail[0]!, ), missingBuiltinFieldMessage( - `Service task 'N'`, + `service task 'N'`, 'mail', BUILTIN_REQUIRED_FIELDS.mail[1]!, ), @@ -1181,30 +1328,25 @@ checks('Validation - the mail and shell bindings', [ [ 'a type beside a code binding is the one-binding conflict and nothing more', `process p { service N(class: "com.example.X", type: "mail") }`, - [bindingConflict(`Service task 'N'`, 'class, type', SERVICE_BINDINGS)], + [bindingConflict(`service task 'N'`, 'class, type', SERVICE_BINDINGS)], ], [ 'a thrown message has no block for the fields, so it takes no type', `process p { start S throw message("Ack", type: "mail") }`, - [notValidOn('type', 'a throw statement')], + [notValidOn('type', 'a throw statement'), unboundSend('throw message')], ], ]); 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')], + [unsupportedScriptTag(`script task 'total'`, 'groovy1')], ], [ - 'an empty script body is one error naming the task', + 'an empty script body is one warning naming the task', `process p { script total ${FENCE}js\n${FENCE} }`, - [emptyScript(`Script task 'total'`)], + [emptyScript(`script task 'total'`)], ], [ 'a supported tag with a body is clean', @@ -1213,7 +1355,7 @@ checks('Validation - script tasks and fenced scripts', [ ], ...scriptTagCases( (tag) => `process p { script total ${FENCE}${tag}\nx = 1\n${FENCE} }`, - `Script task 'total'`, + `script task 'total'`, ), ]); @@ -1625,22 +1767,72 @@ checks('Validation - parallel branch heads', [ }`, [PARALLEL_ELSE_BESIDE_UNCONDITIONED_MESSAGE], ], +]); + +checks("Validation - 'else fail'", [ [ - "a second 'else' branch is one error", + "an 'if' chain and an all-conditioned 'parallel' closed by 'else fail' are clean, and 'fail' stays a variable name", `process p { - var amount: number - parallel { - if (amount > 10000) { user Audit } - else { user A } - else { user B } - } + var fail: boolean + if (fail) { user A } else if (!fail) { user B } else fail + parallel { if (fail) { user C } if (!fail) { user D } else fail } + user E +}`, + [], + ], + [ + "an empty conditioned branch beside 'else fail' carries that case on, so it draws no warning", + `process p { + var a: boolean + if (a) { } else if (!a) { end Stop } else fail + parallel { if (a) { } if (!a) { user B } else fail } + user C +}`, + [], + ], + [ + "a split closed by 'else fail' with every branch ending lets nothing after it run", + `process p { + var a: boolean + if (a) { end X } else fail + user C +}`, + [UNREACHABLE], + ], + [ + "a word other than 'fail' after 'else' is refused on both heads", + `process p { + var a: boolean + if (a) { user A } else stop + parallel { if (a) { user B } if (!a) { user C } else stop } }`, - [PARALLEL_SECOND_ELSE_MESSAGE], + [elseFailWordMessage('stop'), elseFailWordMessage('stop')], + ], + [ + "'else fail' needs a conditioned branch to fail from", + `process p { parallel { { user A } { user B } else fail } }`, + [ELSE_FAIL_WITHOUT_CONDITION_MESSAGE], + ], + [ + "'else fail' beside an unconditioned branch could never happen", + `process p { + var a: boolean + parallel { if (a) { user A } { user B } else fail } +}`, + [ELSE_FAIL_BESIDE_UNCONDITIONED_MESSAGE], + ], + [ + "'else fail' beside an 'else' branch could never happen", + `process p { + var a: boolean + parallel { if (a) { user A } else { user B } else fail } +}`, + [ELSE_FAIL_BESIDE_ELSE_MESSAGE], ], ]); describe('Validation - parallel branch heads, reported in place', () => { - test("the second 'else' carries the error, so the first is left alone", async () => { + test("a second 'else' is one error on that 'else', so the first is left alone", async () => { const { diagnostics } = await validate(` process p { var amount: number @@ -1651,8 +1843,9 @@ process p { } } `); - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0]!.range.start.line).toBe(6); + expect( + withTextMessages(diagnostics).map((d) => [d.range.start.line, textOf(d)]), + ).toEqual([[6, PARALLEL_SECOND_ELSE_MESSAGE]]); }); }); @@ -2255,6 +2448,15 @@ checks('Validation - unreachable statements', [ DEAD_LOOP_MESSAGE, ], ], + [ + 'a link hop to a catch later in the same block leaves the block open', + `process p { + var c: boolean + start S do { user X emit link("R") await link("R") user Y } while (c) + if (c) { emit link("T") await link("T") user A } else { end B } user Alive +}`, + [], + ], [ '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) }`, @@ -2279,6 +2481,19 @@ checks('Validation - unreachable statements', [ }`, [warn(undeclared('c')), UNREACHABLE], ], + [ + 'a goto to any step in a loop body enters the loop after an end', + `process p { + var c: boolean + user A if (c) { goto B } end E do { user B } while (c) + if (c) { goto D } end E2 do { do { user D } while (c) } while (c) + if (c) { goto G } end E3 do { user F user G } while (c) + if (c) { goto Work } end E4 while (c) { user Work } + if (c) { goto Nested } end E5 do { if (c) { user Nested } user M } while (c) + end E6 user Dead +}`, + [UNREACHABLE], + ], [ 'a goto-targeted step after a throw stays reachable', `process p { error X var cond: boolean if (cond) { goto Retry } throw error(X) user Retry }`, @@ -2339,11 +2554,11 @@ checks('Validation - call activities', [ emptyBinding('process', 'process to start'), BINDING_IS_VERSION, BINDING_VALUE, - bindingVersionClash('A call'), + bindingVersionClash('a call'), duplicateMapping('in', 'x'), duplicateAllMapping('in'), bindingConflict( - 'A call', + 'a call', 'mapper, mapperDelegate', `'mapper' or 'mapperDelegate'`, ), @@ -2514,16 +2729,11 @@ checks('Validation - event handlers', [ `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 } }`, [], ], - [ - 'a name-less message handler asks for the name', - `process p { on message { user A } }`, - [noFlowSteps('p'), nameless('message')], - ], - [ - 'a name-less signal handler asks for the name', - `process p { on signal { user A } }`, - [noFlowSteps('p'), nameless('signal')], - ], + ...(['message', 'signal'] as const).map((trigger): Case => [ + `a name-less ${trigger} handler asks for the name`, + `process p { on ${trigger} { user A } }`, + [noFlowSteps('p'), nameless(trigger)], + ]), [ 'a payload-less timer handler asks how to read the time', `process p { on timer { user A } }`, @@ -2714,26 +2924,18 @@ checks('Validation - boundary hosts', [ }`, [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 end event is no activity to attach to', - `process p { error X start S end E on E: error(X) { user A } }`, - [illegalHost('E', 'an end event')], - ], - [ - 'a throw statement is no activity to attach to', - `process p { error PAYMENT_FAILED throw error Foo(PAYMENT_FAILED) on Foo: escalation { user A } }`, - [illegalHost('Foo', 'a throw statement')], - ], - [ - 'an awaited event is no activity to attach to', - `process p { error X await message Wait("M") on Wait: error(X) { user A } }`, - [illegalHost('Wait', 'an awaited event')], - ], + ...( + [ + ['a start event', 'S', 'start S on S: error(X)'], + ['an end event', 'E', 'start S end E on E: error(X)'], + ['a throw statement', 'Foo', 'throw error Foo(X) on Foo: escalation'], + ['an awaited event', 'Wait', 'await message Wait("M") on Wait: error(X)'], + ] as const + ).map(([kind, name, body]): Case => [ + `${kind} is no activity to attach to`, + `process p { error X ${body} { user A } }`, + [illegalHost(name, kind)], + ]), [ 'every activity kind hosts a boundary event', `process p { @@ -3062,16 +3264,11 @@ checks('Validation - the cancel end and its handler', [ }`, [warn(cancelHandlerWithoutEndMessage('A'))], ], - [ - "'throw cancel' points at the end that gives the block up", - `process p { start S throw cancel }`, - [CANCEL_NOT_RAISED_MESSAGE], - ], - [ - "'emit cancel' points there too", - `process p { start S emit cancel }`, + ...(['throw', 'emit'] as const).map((keyword): Case => [ + `'${keyword} cancel' points at the end that gives the block up`, + `process p { start S ${keyword} cancel }`, [CANCEL_NOT_RAISED_MESSAGE], - ], + ]), [ "'await cancel' points at the end and at the handler that catches it", `process p { start S await cancel }`, @@ -3100,7 +3297,7 @@ checks('Validation - throw and emit', [ emit banana("X") throw error }`, - [emitTriggerMessage('banana'), codeRequired('A thrown', 'error', 'throw')], + [emitTriggerMessage('banana'), codeRequired('a thrown', 'error', 'throw')], ], [ 'an empty code is the same mistake as leaving it out entirely', @@ -3109,8 +3306,8 @@ checks('Validation - throw and emit', [ throw escalation("") }`, [ - codeRequired('An emitted', 'signal', 'emit'), - codeRequired('A thrown', 'escalation', 'throw'), + codeRequired('an emitted', 'signal', 'emit'), + codeRequired('a thrown', 'escalation', 'throw'), ], ], [ @@ -3121,7 +3318,19 @@ checks('Validation - throw and emit', [ [ 'a thrown message with no code names the shape', `process p { start S throw message }`, - [codeRequired('A thrown', 'message', 'throw')], + [ + codeRequired('a thrown', 'message', 'throw'), + unboundSend('throw message'), + ], + ], + [ + 'a message emitted or thrown without an implementation warns that it sends nothing', + `process p { start S emit message Notify("N") emit message("M") throw message Done("D") }`, + [ + unboundSend('emit message Notify'), + unboundSend('emit message'), + unboundSend('throw message Done'), + ], ], [ 'a thrown signal is clean', @@ -3155,7 +3364,7 @@ checks('Validation - throw and emit', [ [ 'a thrown message with two implementations names both', `process p { start S throw message("Ack", class: "a", expression: "\${b}") }`, - [bindingConflict('A thrown message', 'class, expression', THROW_BINDINGS)], + [bindingConflict('a thrown message', 'class, expression', THROW_BINDINGS)], ], [ 'a goto targeting a named throw resolves', @@ -3217,12 +3426,12 @@ checks('Validation - awaited events', [ '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: '}'", + "expected a name after '{'", + "expected '{' after ')'", + "expected '=' after 'M'\n" + + "note: inside a block, two plain words start a parameter such as 'input name = value'\n" + + "note: every step starts with a keyword such as 'start', 'user', 'service', 'if', 'on', 'throw', or 'emit'", + "expected a name after '}'", notValidOn('label', 'a branch of an await block'), warn(emptyNumberedBranch(1, 'await')), blockParameter('message'), @@ -3231,19 +3440,16 @@ checks('Validation - awaited events', [ ]); describe('Validation - a race branch runs the plain await rules', () => { - test.each([ - 'message("M")', - 'timer("PT1H")', - 'signal("S")', - 'condition(amount > 100)', - ])('a branch headed `%s` validates clean', async (head) => { + test('a branch headed by each trigger an await takes validates clean', async () => { expect( await diagnosticsOf(` process p { var amount: number await { - ${head} { user A } - signal("Other") { user B } + message("M") { user A } + timer("PT1H") { user B } + signal("S") { user C } + condition(amount > 100) { user D } } } `), @@ -3300,7 +3506,7 @@ checks('Validation - link events', [ [ LINK_IN_RACE_MESSAGE, awaitNameRequired('link'), - codeRequired('An emitted', 'link', 'emit'), + codeRequired('an emitted', 'link', 'emit'), ], ], [ @@ -3314,9 +3520,9 @@ checks('Validation - link events', [ barewordName('link', 'Retry'), barewordName('link', 'Retry'), noImplementation('topic', 'an emitted link'), - linkThrowNeverRunsMessage("Setting 'asyncBefore'"), - linkThrowNeverRunsMessage("Setting 'jobPriority'"), - linkThrowNeverRunsMessage("The 'on end' listener"), + linkThrowNeverRunsMessage("setting 'asyncBefore'"), + linkThrowNeverRunsMessage("setting 'jobPriority'"), + linkThrowNeverRunsMessage("the 'on end' listener"), ], ], [ @@ -3447,31 +3653,19 @@ checks('Validation - end triggers', [ `process p { start S end E terminate("X") }`, [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) }`, - [endTriggerMessage('error')], - ], - [ - 'an escalation is raised with throw', - `process p { escalation Ack start S end E escalation(Ack) }`, - [endTriggerMessage('escalation')], - ], - [ - 'a message is raised with throw', - `process p { start S end E message("Ack") }`, - [endTriggerMessage('message')], - ], - [ - 'a signal is raised with throw', - `process p { start S end E signal("Ack") }`, - [endTriggerMessage('signal')], - ], - [ - 'a compensation is raised with throw', - `process p { start S end E compensation("Ack") }`, - [endTriggerMessage('compensation')], - ], + ...( + [ + ['error', 'error Ack', 'Ack'], + ['escalation', 'escalation Ack', 'Ack'], + ['message', '', '"Ack"'], + ['signal', '', '"Ack"'], + ['compensation', '', '"Ack"'], + ] as const + ).map(([trigger, decl, payload]): Case => [ + `${trigger} is raised with throw, not on an end`, + `process p { ${decl} start S end E ${trigger}(${payload}) }`, + [endTriggerMessage(trigger)], + ]), [ 'a timer end names the places a timer belongs', `process p { start S end E timer("PT1H") }`, @@ -3543,7 +3737,7 @@ checks('Validation - a message or signal name is text, not a reference', [ ], [ 'a thrown message names its subscription', - `process p { start S throw message(OrderReceived) }`, + `process p { start S throw message(OrderReceived, topic: "t") }`, [barewordName('message', 'OrderReceived')], ], [ @@ -3568,59 +3762,66 @@ describe('Validation - paren items, reported in place', () => { "a flag carries the error about the flag, not the handler's code", `process p { error E start S on error(E, alongside) { end F } }`, 'alongside', + alongsideMessage('error'), ], [ 'a terminate end carries it on the payload it must not name', `process p { start S end T terminate(asyncBefore: true, "X") }`, '"X"', + END_TRIGGER_NO_CODE_MESSAGES.terminate, ], [ 'a message start carries it on the name holding the expression', `process p { start S message(asyncBefore: true, "\${x}") }`, '"${x}"', + startMessageExpressionMessage('${x}'), ], [ 'an awaited condition carries it on the code string', `process p { await condition(asyncBefore: true, "X") }`, '"X"', + AWAIT_CONDITION_NO_CODE, ], [ 'a condition handler carries it on the code string', `process p { start S on condition(asyncBefore: true, "X") { end F } }`, '"X"', + CONDITION_NO_CODE, ], [ 'a compensation handler carries it on the payload it must not name', `process p { subprocess S { user U on compensation(asyncBefore: true, "X") { user C } } }`, '"X"', + COMPENSATION_NO_CODE_MESSAGE, ], [ 'a cancel handler carries it on the payload it must not name', `process p { attempt A { user U end G cancel } on A: cancel(asyncBefore: true, "X") { end F } }`, '"X"', + CANCEL_NO_CODE_MESSAGE, ], [ 'an error handler carries it on the empty code', `process p { start S on error(asyncBefore: true, "") { end F } }`, '""', + EMPTY_CODE_MESSAGE, ], [ 'a thrown error carries it on the empty code', `process p { start S throw error(asyncBefore: true, "") }`, '""', + codeRequired('a thrown', 'error', 'throw'), ], [ 'a thrown compensation carries it on the payload it must not name', `process p { start S throw compensation(asyncBefore: true, "X") }`, '"X"', + throwCompensationNames('throw'), ], - ])('%s', async (_title, source, item) => { - const { diagnostics } = await validate(source); - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0]!.range).toEqual({ - start: { line: 0, character: source.indexOf(item) }, - end: { line: 0, character: source.indexOf(item) + item.length }, - }); + ])('%s', async (_title, source, item, message) => { + expect(await placedDiagnosticsOf(source)).toEqual([ + [rangeOf(source, item), message], + ]); }); }); @@ -3675,8 +3876,8 @@ checks('Validation - code declarations', [ 'a mistyped step keyword is named rather than blamed on the brace after it', `process p { usr Review { } }`, [ - "Expecting token of type '}' but found `{`.", - 'Expecting end of file but found `}`.', + "expected '}' after 'Review'", + "unexpected '}' outside any process", noFlowSteps('p'), unknownDeclarationKindMessage('usr'), ], @@ -3760,23 +3961,6 @@ describe('Validation - the task kinds', () => { ]); }, ); - - test.each([ - ['class', 'a step', `process p { step T(class: "com.example.X") }`], - ['decision', 'a receive task', `process p { receive R(decision: "d") }`], - [ - 'message', - 'a send task', - `process p { send N(class: "com.example.Send", message: "M") }`, - ], - ])( - "'%s' on an element that does not own it names the element", - async (key, description, program) => { - expect(await diagnosticsOf(program)).toEqual([ - notValidOn(key, description), - ]); - }, - ); }); describe('Validation - every element with settings and a member block', () => { @@ -3834,20 +4018,14 @@ describe('Validation - every element with settings and a member block', () => { }, ); - test.each([ - ['service', 'service V(topic: "t")'], - ['send', 'send N(topic: "t")'], - ['decide', 'decide D(topic: "t")'], - ])( - 'a topic-bound %s takes a property line and an error mapping', - async (_kind, statement) => { - expect( - await diagnosticsOf( - `process p { error E ${statement} { property k = "v" error E when "\${x}" } }`, - ), - ).toEqual([]); - }, - ); + test('a topic-bound service, send or decision task takes a property line and an error mapping', async () => { + const members = '{ property k = "v" error E when "${x}" }'; + expect( + await diagnosticsOf( + `process p { error E service V(topic: "t") ${members} send N(topic: "t") ${members} decide D(topic: "t") ${members} }`, + ), + ).toEqual([]); + }); }); checks('Validation - input and output parameters', [ @@ -3872,7 +4050,7 @@ lines": 3, "Grüße 日本": 4 } } typeMismatch('count', 'string', 'an ordered comparison', '>='), unknownDirectionMessage('inp', ['input', 'output']), duplicateParameter('input', 'a'), - unsupportedScriptTag(`Input 'xs'`, 'cobol'), + unsupportedScriptTag(`input 'xs'`, 'cobol'), EMPTY_MAP_KEY, EMPTY_MAP_KEY, warn(EMPTY_STRING_VALUE_MESSAGE), @@ -3883,7 +4061,7 @@ lines": 3, "Grüße 日本": 4 } } ], ...scriptTagCases( (tag) => `process p { user U { input x = ${FENCE}${tag}\n1\n${FENCE} } }`, - `Input 'x'`, + `input 'x'`, ), [ 'a parameter name is in scope for the collection it feeds', @@ -3921,9 +4099,9 @@ checks('Validation - injected fields', [ }`, [ noFieldHostMessage('a start event'), - fieldBinding('A service task', 'expression'), - fieldBinding('A service task', 'topic'), - fieldBinding('A decision step', 'decision'), + 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'), @@ -3935,12 +4113,12 @@ checks('Validation - injected fields', [ duplicateParameter('field', 'greeting'), unknownDirectionMessage('fld', ['input', 'output', 'field', 'property']), fieldBindingMessage( - `The 'on start' listener`, + `the 'on start' listener`, ['expression'], LISTENER_FIELD_BINDINGS, ), - scriptListenerFieldMessage(`The 'on start' listener`), - scriptListenerFieldMessage(`The 'on start' listener`), + scriptListenerFieldMessage(`the 'on start' listener`), + scriptListenerFieldMessage(`the 'on start' listener`), unknownDirectionMessage('input', ['field']), ], ], @@ -3948,8 +4126,8 @@ checks('Validation - injected fields', [ '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), + fieldBinding('a service task'), + bindingRequired(`service task 'V'`, SERVICE_BINDINGS), ], ], ]); @@ -3980,12 +4158,12 @@ checks('Validation - external task extras', [ 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']), + 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']), + topicBindingMessage('a service task', 'an error mapping', ['class']), MAPPING_HEAD_MESSAGE, MAPPING_WHEN_MESSAGE, ], @@ -4034,7 +4212,7 @@ checks('Validation - form references on a user task', [ FORM_REF_BINDING_MESSAGE, FORM_REF_MISSING_MESSAGE, FORM_REF_MISSING_MESSAGE, - bindingVersionClash('A user task'), + bindingVersionClash('a user task'), BINDING_IS_VERSION, BINDING_VALUE, ], @@ -4074,26 +4252,26 @@ checks('Validation - listeners', [ unknownListenerEvent('assign', USER_LISTENER_EVENTS), unknownListenerEvent('wibble', `'start' or 'end'`), bindingRequired( - `The 'on start' listener`, + `the 'on start' listener`, LISTENER_BINDINGS, - ', or a fenced script body', + ', nor a fenced script body', ), bindingConflict( - `The 'on start' listener`, + `the 'on start' listener`, 'class, delegate', LISTENER_BINDINGS, ), notValidOn('topic', 'a listener'), bindingRequired( - `The 'on start' listener`, + `the 'on start' listener`, LISTENER_BINDINGS, - ', or a fenced script body', + ', nor a fenced script body', ), LISTENER_TIMER_PAYLOAD_MESSAGE, LISTENER_PARTICLE_ONLY, unknownParticle('whenever'), - unsupportedScriptTag(`The 'on start' listener`, 'php'), - emptyScript(`The 'on start' listener`), + unsupportedScriptTag(`the 'on start' listener`, 'php'), + emptyScript(`the 'on start' listener`), ], ], [ @@ -4104,7 +4282,7 @@ checks('Validation - listeners', [ ...scriptTagCases( (tag) => `process p { user U { on start ${FENCE}${tag}\nx = 1\n${FENCE} } }`, - `The 'on start' listener`, + `the 'on start' listener`, ), ]); @@ -4115,19 +4293,18 @@ describe('Validation - the timer clause, reported in place', () => { 'a listener is marked on its event word', `process p { user U { on timeout(class: "com.example.T") } }`, 'timeout', + LISTENER_TIMER_PAYLOAD_MESSAGE, ], [ 'a catch is marked on its trigger word', `process p { await timer }`, 'timer', + TIMER_PAYLOAD_MESSAGE, ], - ])('%s', async (_title, source, word) => { - const { diagnostics } = await validate(source); - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0]!.range).toEqual({ - start: { line: 0, character: source.indexOf(word) }, - end: { line: 0, character: source.indexOf(word) + word.length }, - }); + ])('%s', async (_title, source, word, message) => { + expect(await placedDiagnosticsOf(source)).toEqual([ + [rangeOf(source, word), message], + ]); }); }); @@ -4289,6 +4466,8 @@ describe('Validation - the repeat clause', () => { 'for "3"', 'for count', `for "\${count}"`, + `for "1\${count}"`, + 'for (count - 2) * 2', ...[ 'nrOfInstances', 'nrOfActiveInstances', @@ -4298,6 +4477,7 @@ describe('Validation - the repeat clause', () => { 'for 1.5', 'for -3', 'for "high"', + `for "1\${a\${count}}"`, ]; expect( await diagnosticsOf( @@ -4307,6 +4487,7 @@ describe('Validation - the repeat clause', () => { REPEAT_COUNT_MESSAGE, REPEAT_COUNT_MESSAGE, REPEAT_COUNT_MESSAGE, + REPEAT_COUNT_MESSAGE, ]); }); @@ -4396,26 +4577,51 @@ checks('Validation - per-run job settings', [ * 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'], - ['a `goto` target', `process p { start S goto while }`, 'while'], - ['a step name', `process p { start S user while end E }`, 'while'], - ['a start name', `process p { start while end E }`, 'while'], +const UNPARSED_SLOT: Array< + [where: string, program: string, expected?: string] +> = [ + [ + 'a `var` name', + `process p { var while: number start S end E }`, + reservedWord('while'), + ], + [ + 'a `goto` target', + `process p { start S goto while }`, + reservedWordAsName('while'), + ], + [ + 'a step name', + `process p { start S user while end E }`, + reservedWordAsName('while'), + ], + [ + 'a start name', + `process p { start while end E }`, + reservedWordAsName('while'), + ], [ 'a subprocess name', `process p { start S subprocess while { user U } end E }`, - 'while', + reservedWordAsName('while'), ], // A container name is only read back when a goto crosses it. + // With no container name to cite, the boundary explanation stands down. [ 'a subprocess name a goto reaches into', `process p { start S subprocess { user U } goto U end E }`, + unresolvedStatement('U'), ], [ 'a subprocess name a goto reaches out of', `process p { start S subprocess { goto Fin } user U end Fin }`, + unresolvedStatement('Fin'), + ], + [ + 'a process name', + `process while { start S end E }`, + reservedWordAsName('while'), ], - ['a process name', `process while { start S end E }`, 'while'], // A duplicate walk is what reads the process name back. [ 'a process name a duplicate step name reports', @@ -4428,53 +4634,72 @@ const UNPARSED_SLOT: Array<[where: string, program: string, word?: string]> = [ [ 'a call name', `process p { start S call while(process: "q") end E }`, - 'while', + reservedWordAsName('while'), ], [ 'a handler binding', `process p { start S on error(while) { start A end B } end E }`, - 'while', + reservedWord('while'), ], [ 'a service task name', `process p { start S service while(class: "C") end E }`, - 'while', + reservedWordAsName('while'), ], [ 'a send task name', `process p { start S send while(class: "C") end E }`, - 'while', + reservedWordAsName('while'), ], [ 'a decision step name', `process p { start S decide while(decision: "d") end E }`, - 'while', + reservedWordAsName('while'), + ], + [ + 'a decision step name left out, beside the checks that do not name it', + `process p { start S decide(binding: "latest", version: 2, decision: "d") end E }`, + bindingVersionClash('a decision step'), ], [ 'a script task name', `process p { start S script while ${FENCE}js\nx()\n${FENCE} end E }`, - 'while', + reservedWordAsName('while'), ], [ 'a handler trigger', `process p { start S on while { step T } end E }`, - 'while', + reservedWord('while'), ], [ 'a handler trigger a goto reaches into', `process p { start S on { step T } goto T end E }`, + unresolvedStatement('T'), ], [ 'a handler trigger a goto reaches out of', `process p { start S on { goto T } step T end E }`, + unresolvedStatement('T'), + ], + [ + 'a throw trigger', + `process p { start S throw while }`, + reservedWord('while'), + ], + [ + 'an emit trigger', + `process p { start S emit while end E }`, + reservedWord('while'), + ], + [ + 'an await trigger', + `process p { start S await while end E }`, + reservedWord('while'), ], - ['a throw trigger', `process p { start S throw while }`, 'while'], - ['an emit trigger', `process p { start S emit while end E }`, 'while'], - ['an await trigger', `process p { start S await while end E }`, 'while'], [ 'a listener event', `process p { start S user U { on while(class: "C") } end E }`, - 'while', + reservedWord('while'), ], [ // The colliding `var` makes the agreement check read the type. @@ -4484,7 +4709,7 @@ const UNPARSED_SLOT: Array<[where: string, program: string, word?: string]> = [ [ 'a code declaration name', `process p { error while start S end E }`, - 'while', + reservedWordAsName('while'), ], [ 'a code declaration setting value', @@ -4495,69 +4720,39 @@ const UNPARSED_SLOT: Array<[where: string, program: string, word?: string]> = [ [ 'a call mapping target', `process p { start S call C(process: "q") { in in } end E }`, - 'in', + reservedWord('in'), ], [ 'a handler trigger compared for duplicates', `process p { start S on on { step T } end E }`, - 'on', + reservedWord('on'), ], [ 'a script task body left unterminated', `process p { start S script total ${FENCE}js\nx = 1\n }`, + unterminatedScript('total'), ], ]; describe('Validation - an unparsed slot', () => { - test('no unparsed slot crashes a check or draws a diagnostic naming what it cannot name', async () => { + test('no unparsed slot crashes a check or draws a diagnostic naming what it cannot name, and the expected one is drawn once', async () => { const problems: string[][] = []; - for (const [where, program, word] of UNPARSED_SLOT) { + for (const [where, program, expected] of UNPARSED_SLOT) { const messages = await diagnosticsOf(program); 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)}`); + const drawn = messages.filter((m) => m === expected).length; + if (expected !== undefined && drawn !== 1) { + wrong.push(`drawn ${drawn} times: ${expected}`); } if (wrong.length > 0) problems.push([where, ...wrong]); } expect(problems).toEqual([]); }); - // 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 }`, - ); - expect(messages.filter((m) => m === unterminatedScript('total'))).toEqual([ - unterminatedScript('total'), - ]); - }); - - test('a nameless decision step still gets the checks that do not name it', async () => { - expect( - await diagnosticsOf( - `process p { start S decide(binding: "latest", version: 2, decision: "d") end E }`, - ), - ).toContain(bindingVersionClash('A decision step')); - }); - test('two reserved words as `var` names report both and nothing else', async () => { expect( await diagnosticsOf( diff --git a/packages/language/test/vocabulary-sync.test.ts b/packages/language/test/vocabulary-sync.test.ts index 5eb4f89..8288bf5 100644 --- a/packages/language/test/vocabulary-sync.test.ts +++ b/packages/language/test/vocabulary-sync.test.ts @@ -7,6 +7,7 @@ import { describe, expect, test } from 'vitest'; import { EmptyFileSystem } from 'langium'; import { CATCH_TRIGGERS, + ELSE_FAIL_WORD, EMIT_TRIGGERS, END_TRIGGERS, EXECUTION_LISTENER_EVENTS, @@ -140,6 +141,47 @@ describe('the TextMate grammar scopes a line', () => { }); }); +describe('the TextMate grammar and the lexer agree on a name in backticks', () => { + test('the name pattern runs before the keywords, so a keyword inside colours as a name', () => { + const names = grammarPatterns().map((p) => p.name); + expect(names.indexOf('entity.name.bpmn-script')).toBe( + names.indexOf('keyword.control.bpmn-script') - 1, + ); + }); + + test.each([ + ['a keyword in backticks', 'user `process`', ['`process`']], + ['an escaped backtick stays inside', 'goto `a\\`b`', ['`a\\`b`']], + ['two names on one line', 'on `T.1`: error(`x y`)', ['`T.1`', '`x y`']], + ['a fence opener is no name', 'script S ```js', []], + ])('%s', (_title, line, expected) => { + const lexer = + createBpmnScriptServices(EmptyFileSystem).BpmnScript.parser.Lexer; + const lexed = lexer + .tokenize(line) + .tokens.filter((t) => t.tokenType.name === 'QUOTED_NAME') + .map((t) => t.image); + expect([tokensOf(line, 'entity.name'), lexed]).toEqual([ + expected, + expected, + ]); + }); +}); + +test("the TextMate grammar colours the word the parser reads after 'else' as the fallback that fails, and not as a variable name", () => { + const pattern = grammarPatterns().find( + (p) => p.match?.includes('(else)') === true, + ); + const scoped = (line: string): string[] => + [...line.matchAll(new RegExp(pattern?.match ?? '$^', 'g'))].flatMap((m) => + m.slice(1), + ); + expect([ + scoped(` } else ${ELSE_FAIL_WORD}`), + scoped(` var ${ELSE_FAIL_WORD}: boolean`), + ]).toEqual([['else', ELSE_FAIL_WORD], []]); +}); + describe('splitFencedScript', () => { test('a CRLF-checked-out source normalizes the body to LF', () => { expect( diff --git a/packages/language/textmate-postprocess.mjs b/packages/language/textmate-postprocess.mjs index fd42270..c571c6a 100644 --- a/packages/language/textmate-postprocess.mjs +++ b/packages/language/textmate-postprocess.mjs @@ -4,7 +4,8 @@ // 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. +// keyword. It also emits no scope for numbers or expression operators, and +// none for a name in backticks, whose text would colour as a keyword. import { readFileSync, writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; @@ -33,6 +34,29 @@ const OPERATOR_PATTERN = { match: String.raw`\|\||&&|==|!=|<=|>=|<|>|\+|\*|\/|%|!|\?|(? @@ -52,6 +76,24 @@ if (boundary) { ); } +if ( + !grammar.patterns.some((pattern) => pattern.match === ELSE_FAIL_PATTERN.match) +) { + grammar.patterns.splice( + grammar.patterns.indexOf(keywordPattern), + 0, + ELSE_FAIL_PATTERN, + ); +} +if ( + !grammar.patterns.some((pattern) => pattern.name === QUOTED_NAME_PATTERN.name) +) { + grammar.patterns.splice( + grammar.patterns.indexOf(keywordPattern), + 0, + QUOTED_NAME_PATTERN, + ); +} if ( !grammar.patterns.some((pattern) => pattern.name === NUMERIC_PATTERN.name) ) { diff --git a/packages/transform/README.md b/packages/transform/README.md index 6195bc1..e7a5729 100644 --- a/packages/transform/README.md +++ b/packages/transform/README.md @@ -1,7 +1,6 @@ # @bpmn-script/transform 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-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. @@ -39,7 +38,7 @@ interface BpmnProcess { documentation?: string; // bpmn:documentation, carried verbatim from a single plaintext child 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) + historyTimeToLive?: string; // operaton:historyTimeToLive, absent only on import; the exporter then writes P30D candidateStarterUsers?: string; // operaton:candidateStarterUsers, comma-separated user ids stored as written candidateStarterGroups?: string; // operaton:candidateStarterGroups, comma-separated group ids isStartableInTasklist?: boolean; // operaton:isStartableInTasklist, absent means the engine default (true) @@ -72,19 +71,20 @@ interface SequenceFlow { sourceRef: string; // id of source FlowElement targetRef: string; // id of target FlowElement conditionExpression?: string; // e.g. "${amount > 1000}" + conditionDocumentation?: string; // the bpmn:documentation inside the condition } ``` 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-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)). +Every gateway kind carries the five settings too, written on the statement head that synthesizes it, but no listeners: the four gateway types extend `JobSettings` rather than the wider `EngineAttributes`, since a listener needs a textual identity a synthesized gateway lacks. 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. +The count, the element variable and the completion condition each carry an optional `...Documentation` beside them, as a timer definition does for its `expression` and a conditional one for its `condition`: the `bpmn:documentation` inside that value's own element, with a documented element variable written as a named `bpmn:inputDataItem`. `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. @@ -98,12 +98,13 @@ A `type` this surface cannot carry therefore refuses the document rather than fa `SubProcess` is itself a `FlowContainer`, so the IR is recursive: a sub-process nests its own `flowElements` and `sequenceFlows` at any depth, and no sequence flow crosses a container boundary. `element` picks which of the two tags it serializes to, absent for `bpmn:subProcess` and `'transaction'` for `bpmn:transaction`, which the DSL writes with the `attempt` head. -Operaton runs the second tag through the very behavior class it gives the first, so the tag changes nothing about how the block executes; what it buys is that the engine then accepts a cancel end inside the block and a cancel boundary on it, and refuses to deploy either anywhere else. +Operaton runs both tags through the same behavior class, so the tag changes nothing about how the block executes. +What it changes is that the engine accepts a cancel end inside the block and a cancel boundary on it, and refuses to deploy either anywhere else. Event semantics ride on an `eventDefinition` field, optional on a start or end event and required on an intermediate throw, an intermediate catch, and a boundary event. -`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. +`terminate` and `cancel` join the union on an end event, `cancel` also on a boundary event, and both are 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`. +A compensation definition may sit on a start, an end, or an intermediate throw, but not on an intermediate catch or a `BoundaryEvent`, since BPMN's compensation boundary works through `isForCompensation` and an association, which the IR does not model. `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. @@ -122,16 +123,17 @@ A token appears at a `BoundaryEvent` while its host activity (named by `attached import { astToIr, irToXml, xmlToIr, irToDsl } from '@bpmn-script/transform'; const ir: BpmnProcess = astToIr(langiumAstModel); // sync -const xml: string = await irToXml(ir); // async; adds bpmndi: layout data +const xml: string = await irToXml(ir, { exporterVersion: '0.1.0' }); // async; adds bpmndi: layout data const { ir: imported, warnings } = await xmlToIr(xmlString); // async; discards DI, may throw 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, `LayoutError`, and the `ImportWarning` and `PrintWarning` types. +`exporterVersion` is written on `bpmn:definitions` and defaults to `0.0.0`. +`src/index.ts` also exports 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`, the `ImportWarning` and `PrintWarning` types, and the input checks the CLI and the extension share (`xmlInputProblem`, `readableParseError`). ## The import contract -`xmlToIr` never discards an element without saying so. +Apart from the diagram interchange data ([ADR-0009](../../docs/decisions/0009-auto-layout-and-expansion-hint.md)), `xmlToIr` never drops an element without saying so. 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. @@ -140,14 +142,15 @@ A document whose root is not `bpmn:definitions` or a sequence flow whose `source 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-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. 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 file declaring both prefixes keeps what the engine runs: `Element.attributeNS`, `elementNS`, and `elementsNS` look under the camunda URI only when the operaton one finds nothing, per attribute name on one element and per child tag name under one parent. +A `camunda:` attribute beside its `operaton:` twin, and every `camunda:` child sharing a tag name with an `operaton:` sibling, is left out with one `extensionAttribute` warning per element, so an `operaton:executionListener` beside a `camunda:executionListener` imports alone. +Children the engine fetches by tag alone import under both spellings: list items, map entries, and an external task's `errorEventDefinition`. 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. @@ -156,8 +159,6 @@ The same attribute on an Operaton extension element the IR reads is reported, a The other is a BPMN attribute this surface neither reads nor reports: a process's `processType` or `isClosed`. Each of those is content left out. A lone `operaton:resource` on a sequence flow's condition expression or a conditional event definition's condition is the one exception that is reported rather than dropped: Operaton reads it only alongside a `language`, so on its own it reaches nothing, and the condition still imports as the expression its body writes. -`isExecutable="false"`, whose import changes what the document says, is reported instead of left for the reader to notice, and so is an absent `isExecutable`, which `BpmnParse.parseProcessDefinitions` 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 @@ -172,7 +173,7 @@ It is a `bpmn:import`, which `BpmnParse.parseImports` fails on for every type bu 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. +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 ``, `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. +It also covers a label on a start whose id is the one the compiler mints for its container (`StartEvent_`), since a script cannot spell that id back and the statement that would have carried the label is left out whole; a label on an end under such an id (`EndEvent_`, or `EndEvent_` for a boundary escape) makes the end print 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, unless it is an error root carrying an `operaton:errorMessage`, which refuses; one carrying a code imports as a declaration whether or not anything raises it. `documentation` covers `bpmn:documentation` at every position where `label` above is warned, for the same reason: an event handler, a typed end event, an intermediate throw, an intermediate catch, a boundary event, and a start or an end whose id is the one the compiler mints for its container. -It also covers a `bpmn:documentation` on a position that reads no `name` at all: the definitions root, an event definition, an external task's `operaton:errorEventDefinition`, an Error/Escalation/Message/Signal root, a multi-instance loop characteristics element, and a sequence flow. +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, a `bpmn:inputDataItem` without a name, 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-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. That split runs on the commas outside an expression alone, so a list written as one `${...}` or `#{...}` body stays a single entry. @@ -331,7 +332,6 @@ An `operaton:jobPriority` there joins them, since Operaton reads a job priority 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. 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` 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. @@ -353,8 +353,8 @@ A repeated enum value id on a form field is kept the way `FormTypes.parseFormPro `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. +An absent `isExecutable` on a lone process is reported the same way, since `BpmnParse.parseProcessDefinitions` defaults it to `!deployment.isNew()` and so skips the process in every deployment of a new resource. +An absent `operaton:historyTimeToLive` is written back as `P30D`, so the rebuilt process deploys where the source, which `HistoryTimeToLiveParser.parseAndValidate` refuses 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. @@ -375,7 +375,7 @@ Refusing either would reject a file the engine deploys although the rebuilt docu `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. +Every message names the element it is about, and `elementId` carries its id. ```ts interface PrintWarning { @@ -388,8 +388,12 @@ interface PrintWarning { | 'degradedSplit' | 'droppedCondition' | 'refusedStatement' - | 'renamedId' - | 'droppedSetting'; + | 'droppedSetting' + | 'skippedJoin' + | 'addedJoin' + | 'changedJoin' + | 'nestedFork' + | 'unreachable'; message: string; // names what was left out or changed and whether the process runs the same } ``` @@ -400,8 +404,7 @@ Every other elided label and documentation is reported by `xmlToIr`, so a caller `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. +`defaultFlow` covers a fallback the script cannot write as the model has it. 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. @@ -414,12 +417,23 @@ On a route beside an unconditioned fallback the model diverts the run rather tha `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. +`skippedJoin` covers a jump printed past a join: a route that takes it goes on at once instead of waiting for the others, so the steps after the join run once per such route. + +`addedJoin` covers the routes out of a split that stop without an end event. +The script has no form for such a route, so the branches of the printed `parallel` block meet at a join after it, and the rebuilt process holds each route there until the others arrive where the engine ended each one where it stopped. + +`changedJoin` covers an inclusive join printed as the join of a `parallel` block none of whose branches carries a condition, which reads back as a parallel join. +A parallel join waits for a route from every branch, so a route led away from its branch before the join, by an interrupting boundary event for one, leaves the rebuilt process waiting there forever. + +`nestedFork` covers a parallel fork some of whose routes meet at a join of their own before the fork's join. +The script prints them as nested `parallel` blocks, adding a fork ahead of those routes, and the process runs the same. + +`unreachable` covers a step that no start event, boundary event or event handler leads to. +The engine never runs it, so the script leaves it out. + ## Build and test ```bash @@ -445,12 +459,15 @@ npm test | `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/humanize.ts` | `humanize`: the label the export derives from a task or sub-process id | +| `src/host-messages.ts` | Messages for files that cannot be converted, shared by the CLI and the extension | +| `src/bpmn-moddle.d.ts` | Type declarations for `bpmn-moddle`, `bpmn-auto-layout` and `saxen`, which ship none | | `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`. +`irToXml` uses `bpmn-moddle@^10` and `bpmn-auto-layout@^1.3.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. @@ -462,7 +479,7 @@ The Operaton namespace comes from `src/operaton-moddle.json`, a trimmed fork of Every synthesized gateway, flow, and boundary-event id comes from `synthesize-ids.ts`. Gateway and flow ids are positional, derived from the element's structural coordinate. -An unnamed throw or catch is minted a positional id the same way (`Throw__`, `Catch__`); compiling the same unnamed statement again after the printer reordered it (a multi-start chain, a hoisted branch) renumbers the id, which is cosmetic and does not change what the process runs. +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-synthesized-ids.md), [ADR-0027](../../docs/decisions/0027-boundary-events-attached-to-an-activity.md)). diff --git a/packages/transform/package.json b/packages/transform/package.json index 636ee1b..f739a38 100644 --- a/packages/transform/package.json +++ b/packages/transform/package.json @@ -1,7 +1,7 @@ { "name": "@bpmn-script/transform", "description": "IR type definitions and bidirectional BPMN transforms for BPMNscript", - "version": "0.0.1", + "version": "0.1.0", "license": "Apache-2.0", "repository": { "type": "git", @@ -33,20 +33,22 @@ } }, "scripts": { - "clean": "shx rm -fr *.tsbuildinfo out", - "build": "tsc -p tsconfig.json && shx cp src/operaton-moddle.json out/", - "build:post": "shx cp src/operaton-moddle.json out/", + "prepack": "node -e \"fs.cpSync('../../LICENSE', 'LICENSE')\"", + "clean": "node -e \"for (const f of [...fs.globSync('*.tsbuildinfo'), 'out']) fs.rmSync(f, { recursive: true, force: true })\"", + "build": "tsc -p tsconfig.json && node -e \"fs.cpSync('src/operaton-moddle.json', 'out/operaton-moddle.json')\"", + "build:post": "node -e \"fs.cpSync('src/operaton-moddle.json', 'out/operaton-moddle.json')\"", "build:clean": "npm run clean && npm run build", "typecheck": "tsc -b tsconfig.test.json", "test": "vitest run" }, "dependencies": { - "bpmn-moddle": "^10.2.0", - "bpmn-auto-layout": "^1.2.0", - "saxen": "^11.1.0", - "@bpmn-script/language": "*" + "@bpmn-script/language": "0.1.0", + "bpmn-auto-layout": "^1.3.0", + "bpmn-moddle": "^10.3.1", + "langium": "^4.4.0", + "saxen": "^11.1.0" }, "devDependencies": { - "vitest": "^4.1.11" + "vitest": "^5.0.5" } } diff --git a/packages/transform/src/ast-to-ir.ts b/packages/transform/src/ast-to-ir.ts index 9d09bce..34f714f 100644 --- a/packages/transform/src/ast-to-ir.ts +++ b/packages/transform/src/ast-to-ir.ts @@ -82,6 +82,9 @@ import { TIMER_PARTICLE_BY_KIND, type TimerKind, isNamedStatement, + DOCUMENTATION_KEY, + VALUE_DOCUMENTATION_KEYS, + EXPRESSION_ANYWHERE, } from '@bpmn-script/language'; import type { Model, @@ -177,8 +180,9 @@ import { makeBoundaryEventId, makeIntermediateCatchEventId, claimDeclarationName, - resolveCollision, + claimId, } from './synthesize-ids.js'; +import { HISTORY_TIME_TO_LIVE } from './ir-to-xml.js'; /** * A `null` `exit` suppresses both the implicit flow to the next sibling and the @@ -226,20 +230,7 @@ export function astToIr(model: Model): BpmnProcess { lowerContainerBody(builder, process.body, process.name, process.name); - const versionTag = processSetting(process, 'versionTag'); - const historyTimeToLive = processSetting(process, 'historyTimeToLive'); - const candidateStarterUsers = processSetting( - process, - 'candidateStarterUsers', - ); - const candidateStarterGroups = processSetting( - process, - 'candidateStarterGroups', - ); - const isStartableInTasklist = boolAttrValue( - settingsOf(process.items), - STARTABLE_KEY, - ); + const settings = settingsOf(process.items); const { errorCodes, escalationCodes } = eventIdentities({ id: process.name, flowElements: builder.flowElements, @@ -255,11 +246,14 @@ export function astToIr(model: Model): BpmnProcess { id: process.name, ...namedAttrs(process), isExecutable: true, - ...(versionTag !== undefined ? { versionTag } : {}), - ...(historyTimeToLive !== undefined ? { historyTimeToLive } : {}), - ...(candidateStarterUsers !== undefined ? { candidateStarterUsers } : {}), - ...(candidateStarterGroups !== undefined ? { candidateStarterGroups } : {}), - ...(isStartableInTasklist !== undefined ? { isStartableInTasklist } : {}), + ...defined({ versionTag: attrValue(settings, 'versionTag') }), + historyTimeToLive: + attrValue(settings, 'historyTimeToLive') ?? HISTORY_TIME_TO_LIVE, + ...defined({ + candidateStarterUsers: attrValue(settings, 'candidateStarterUsers'), + candidateStarterGroups: attrValue(settings, 'candidateStarterGroups'), + isStartableInTasklist: boolAttrValue(settings, STARTABLE_KEY), + }), flowElements: builder.flowElements, sequenceFlows: builder.sequenceFlows, ...(errorDecls.length > 0 ? { errorDecls } : {}), @@ -267,6 +261,8 @@ export function astToIr(model: Model): BpmnProcess { }; } +type CodeDecls = Required>; + interface DeclaredCode { name?: string; code: string; @@ -283,10 +279,7 @@ function collectCodeDecls( process: Process, usedErrorCodes: ReadonlySet, usedEscalationCodes: ReadonlySet, -): { - errorDecls: { name: string; code: string; message?: string }[]; - escalationDecls: { name: string; code: string }[]; -} { +): CodeDecls { const errors = new Map(); const escalations = new Map(); for (const decl of process.decls) { @@ -319,7 +312,7 @@ function collectCodeDecls( const errorDecls = canonical(usedErrorCodes, errors).map((d) => ({ name: claimDeclarationName(d.code, taken, d.name), code: d.code, - ...(d.message !== undefined ? { message: d.message } : {}), + ...defined({ message: d.message }), })); const escalationDecls = canonical(usedEscalationCodes, escalations).map( (d) => ({ @@ -336,7 +329,7 @@ function lowerContainerBody( coord: string, containerId: string, ): void { - const body = lowerBlockStatements(builder, statements, coord); + const body = lowerBlock(builder, statements, coord); if (body.entry !== null) { const firstIsExplicitStart = @@ -370,9 +363,10 @@ function lowerContainerBody( * 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. + * the next step. A branch's `coord` carries its segment (`_t`, `_e`, + * `_b`); each statement's coordinate appends its index. */ -function lowerBlockStatements( +function lowerBlock( builder: Builder, statements: Statement[], coord: string, @@ -383,17 +377,18 @@ function lowerBlockStatements( let waitingStarts: string[] = []; statements.forEach((stmt, index) => { + const x = `${coord}_${index}`; if (isOnHandler(stmt)) { if (stmt.host !== undefined) { // As in `lowerGoto`, `$refText` survives an unresolved host. - lowerBoundaryHandler(builder, stmt, stmt.host.$refText, coord, index); + lowerBoundaryHandler(builder, stmt, stmt.host.$refText, x); } else { - lowerOnHandler(builder, stmt, coord, index); + lowerOnHandler(builder, stmt, x); } return; } - const frontier = lowerStatement(builder, stmt, coord, index); + const frontier = lowerStatement(builder, stmt, x); const stmtEntry = frontier.entry!; if (entry === null) { @@ -421,24 +416,15 @@ function lowerBlockStatements( return { entry, exit, - ...(exitFlowId !== undefined ? { exitFlowId } : {}), + ...defined({ exitFlowId }), ...(waitingStarts.length > 0 ? { waitingStarts } : {}), }; } -/** - * 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); -} - function lowerStatement( builder: Builder, stmt: Statement, - coord: string, - index: number, + x: string, ): Frontier { if (isStartEvent(stmt)) { return lowerStartEvent(builder, stmt); @@ -450,55 +436,55 @@ function lowerStatement( return lowerUserTask(builder, stmt); } if (isServiceTask(stmt)) { - return lowerServiceTask(builder, stmt); + return lowerServiceTaskLike(builder, stmt); } if (isGenericTask(stmt)) { return lowerGenericTask(builder, stmt); } if (isSendTask(stmt)) { - return lowerSendTask(builder, stmt); + return lowerServiceTaskLike(builder, stmt, 'send'); } if (isReceiveTask(stmt)) { return lowerReceiveTask(builder, stmt); } if (isBusinessRuleTask(stmt)) { - return lowerBusinessRuleTask(builder, stmt); + return lowerServiceTaskLike(builder, stmt, 'businessRule'); } if (isScriptTask(stmt)) { return lowerScriptTask(builder, stmt); } if (isIfStatement(stmt)) { - return lowerIf(builder, stmt, `${coord}_${index}`); + return lowerIf(builder, stmt, x); } if (isWhileStatement(stmt)) { - return lowerWhile(builder, stmt, `${coord}_${index}`); + return lowerWhile(builder, stmt, x); } if (isDoWhileStatement(stmt)) { - return lowerDoWhile(builder, stmt, `${coord}_${index}`); + return lowerDoWhile(builder, stmt, x); } if (isParallelStatement(stmt)) { - return lowerParallel(builder, stmt, `${coord}_${index}`); + return lowerParallel(builder, stmt, x); } if (isRaceStatement(stmt)) { - return lowerRace(builder, stmt, `${coord}_${index}`); + return lowerRace(builder, stmt, x); } if (isGotoStatement(stmt)) { return lowerGoto(stmt); } if (isSubProcess(stmt)) { - return lowerSubProcess(builder, stmt, `${coord}_${index}`); + return lowerSubProcess(builder, stmt, x); } if (isCallActivity(stmt)) { return lowerCallActivity(builder, stmt); } if (isThrowStatement(stmt)) { - return lowerThrow(builder, stmt, coord, index); + return lowerThrow(builder, stmt, x); } if (isEmitStatement(stmt)) { - return lowerEmit(builder, stmt, coord, index); + return lowerEmit(builder, stmt, x); } if (isIntermediateCatchEvent(stmt)) { - return lowerIntermediateCatch(builder, stmt, coord, index); + return lowerIntermediateCatch(builder, stmt, x); } throw new Error( `astToIr: unexpected statement type '${(stmt as { $type: string }).$type}'.`, @@ -506,16 +492,15 @@ function lowerStatement( } function lowerStartEvent(builder: Builder, stmt: AstStartEvent): Frontier { - const formFields = lowerFormFields(stmt); - const eventDefinition = startEventDefinition(stmt); - const initiator = attrValue(settingsOf(stmt.items), 'initiator'); builder.flowElements.push({ kind: 'startEvent', id: stmt.name, ...namedAttrs(stmt), - ...(formFields !== undefined ? { formFields } : {}), - ...(eventDefinition !== undefined ? { eventDefinition } : {}), - ...(initiator !== undefined ? { initiator } : {}), + ...defined({ + formFields: lowerFormFields(stmt), + eventDefinition: startEventDefinition(stmt), + initiator: attrValue(settingsOf(stmt.items), 'initiator'), + }), ...readEngineAttributes(stmt), }); return { entry: stmt.name, exit: stmt.name }; @@ -551,12 +536,11 @@ function endEventDefinition( } function lowerEndEvent(builder: Builder, stmt: AstEndEvent): Frontier { - const eventDefinition = endEventDefinition(stmt.trigger); builder.flowElements.push({ kind: 'endEvent', id: stmt.name, ...namedAttrs(stmt), - ...(eventDefinition !== undefined ? { eventDefinition } : {}), + ...defined({ eventDefinition: endEventDefinition(stmt.trigger) }), ...readEngineAttributes(stmt), }); return { entry: stmt.name, exit: null }; @@ -564,30 +548,22 @@ function lowerEndEvent(builder: Builder, stmt: AstEndEvent): Frontier { function lowerUserTask(builder: Builder, stmt: AstUserTask): Frontier { 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(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', id: stmt.name, ...namedAttrs(stmt), - ...(assignee !== undefined ? { assignee } : {}), - ...(formKey !== undefined ? { formKey } : {}), - ...(formRef !== undefined ? { formRef } : {}), - ...(formFields !== undefined ? { formFields } : {}), - ...(candidateGroups !== undefined ? { candidateGroups } : {}), - ...(candidateUsers !== undefined ? { candidateUsers } : {}), - ...(dueDate !== undefined ? { dueDate } : {}), - ...(followUpDate !== undefined ? { followUpDate } : {}), - ...(priority !== undefined ? { priority } : {}), - ...(taskListeners !== undefined ? { taskListeners } : {}), + ...defined({ + assignee: attrValue(settings, 'assignee'), + formKey: attrValue(settings, 'formKey'), + formRef: readFormRef(settings), + formFields: lowerFormFields(stmt), + candidateGroups: attrValue(settings, 'candidateGroups'), + candidateUsers: attrValue(settings, 'candidateUsers'), + dueDate: attrValue(settings, 'dueDate'), + followUpDate: attrValue(settings, 'followUpDate'), + priority: numericOrElAttrValue(settings, 'priority'), + taskListeners: readTaskListeners(stmt.listeners), + }), ...readLoop(stmt), ...readIoParameters(stmt.params), ...readEngineAttributes(stmt), @@ -619,24 +595,25 @@ function lowerFormFields( function lowerFormField(f: AstFormField): FormField { const settings = settingsOf(f.items); - const datePattern = attrValue(settings, DATE_PATTERN_KEY); const constraints = readFormFieldConstraints(settings); const values: FormFieldValue[] = f.values.map((v) => ({ id: v.id, - ...(v.label !== undefined ? { label: v.label } : {}), + ...defined({ label: v.label }), })); - const properties = readExtensionProperties(f.params); return { id: f.id, type: toFormFieldType(f.type), - ...(f.label !== undefined ? { label: f.label } : {}), - ...(f.defaultValue !== undefined - ? { defaultValue: renderFormDefault(f.defaultValue) } - : {}), - ...(datePattern !== undefined ? { datePattern } : {}), + ...defined({ + label: f.label, + defaultValue: + f.defaultValue === undefined + ? undefined + : renderFormDefault(f.defaultValue), + datePattern: attrValue(settings, DATE_PATTERN_KEY), + }), ...(values.length > 0 ? { values } : {}), ...(constraints.length > 0 ? { constraints } : {}), - ...(properties !== undefined ? { properties } : {}), + ...defined({ properties: readExtensionProperties(f.params) }), }; } @@ -699,10 +676,13 @@ function renderFormDefault(expr: Expr): string { function lowerServiceTaskLike( builder: Builder, stmt: AstServiceTask | AstSendTask | AstBusinessRuleTask, - binding: ServiceTaskBinding, element?: IrServiceTask['element'], ): Frontier { - const resultVariable = attrValue(settingsOf(stmt.items), 'resultVariable'); + const settings = settingsOf(stmt.items); + const binding = + element === 'businessRule' + ? businessRuleBinding(settings) + : serviceTaskBinding(settings); builder.flowElements.push({ kind: 'serviceTask', id: stmt.name, @@ -713,8 +693,10 @@ function lowerServiceTaskLike( : binding.kind === 'decision' ? binding : withDeclaredFields(binding, stmt.params), - ...(resultVariable !== undefined ? { resultVariable } : {}), - ...(element !== undefined ? { element } : {}), + ...defined({ + resultVariable: attrValue(settings, 'resultVariable'), + element, + }), ...readLoop(stmt), ...readIoParameters(stmt.params), ...readEngineAttributes(stmt), @@ -722,14 +704,6 @@ function lowerServiceTaskLike( return { entry: stmt.name, exit: stmt.name }; } -function lowerServiceTask(builder: Builder, stmt: AstServiceTask): Frontier { - return lowerServiceTaskLike( - builder, - stmt, - serviceTaskBinding(settingsOf(stmt.items)), - ); -} - function codeBinding(attrs: KeyValueAttr[]): CodeBinding | undefined { const className = attrValue(attrs, 'class'); if (className !== undefined) { @@ -788,22 +762,12 @@ function lowerGenericTask(builder: Builder, stmt: AstGenericTask): Frontier { return { entry: stmt.name, exit: stmt.name }; } -function lowerSendTask(builder: Builder, stmt: AstSendTask): Frontier { - return lowerServiceTaskLike( - builder, - stmt, - serviceTaskBinding(settingsOf(stmt.items)), - 'send', - ); -} - function lowerReceiveTask(builder: Builder, stmt: AstReceiveTask): Frontier { - const messageName = attrValue(settingsOf(stmt.items), 'message'); builder.flowElements.push({ kind: 'receiveTask', id: stmt.name, ...namedAttrs(stmt), - ...(messageName !== undefined ? { messageName } : {}), + ...defined({ messageName: attrValue(settingsOf(stmt.items), 'message') }), ...readLoop(stmt), ...readIoParameters(stmt.params), ...readEngineAttributes(stmt), @@ -811,33 +775,21 @@ function lowerReceiveTask(builder: Builder, stmt: AstReceiveTask): Frontier { return { entry: stmt.name, exit: stmt.name }; } -function lowerBusinessRuleTask( - builder: Builder, - stmt: AstBusinessRuleTask, -): Frontier { - return lowerServiceTaskLike( - builder, - stmt, - businessRuleBinding(settingsOf(stmt.items)), - 'businessRule', - ); -} - /** Without `decision` the block reads as a service task's. */ function businessRuleBinding(attrs: KeyValueAttr[]): ServiceTaskBinding { const decisionRef = attrValue(attrs, 'decision'); if (decisionRef === undefined) { return serviceTaskBinding(attrs); } - const binding = versionBinding(attrs); const mapping = attrValue(attrs, 'mapDecisionResult'); - const mapDecisionResult = - mapping === undefined ? undefined : toDecisionResultMapping(mapping); return { kind: 'decision', decisionRef, - ...(binding !== undefined ? { binding } : {}), - ...(mapDecisionResult !== undefined ? { mapDecisionResult } : {}), + ...defined({ + binding: versionBinding(attrs), + mapDecisionResult: + mapping === undefined ? undefined : toDecisionResultMapping(mapping), + }), }; } @@ -853,14 +805,15 @@ function toDecisionResultMapping(mapping: string) { function lowerScriptTask(builder: Builder, stmt: AstScriptTask): Frontier { const { tag, code } = splitFencedScript(stmt.body); - const resultVariable = attrValue(settingsOf(stmt.items), 'resultVariable'); builder.flowElements.push({ kind: 'scriptTask', id: stmt.name, ...namedAttrs(stmt), format: scriptFormatOf(tag) ?? tag, code, - ...(resultVariable !== undefined ? { resultVariable } : {}), + ...defined({ + resultVariable: attrValue(settingsOf(stmt.items), 'resultVariable'), + }), ...readLoop(stmt), ...readIoParameters(stmt.params), ...readEngineAttributes(stmt), @@ -871,18 +824,23 @@ function lowerScriptTask(builder: Builder, stmt: AstScriptTask): Frontier { /** * 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. + * never conditioned: Operaton rejects a conditioned default. `else fail` has + * none, so `ExclusiveGatewayActivityBehavior.doLeave` throws when no condition + * holds. */ function lowerIf(builder: Builder, stmt: IfStatement, x: string): Frontier { const splitId = makeGatewaySplitId(x); const joinId = makeGatewayJoinId(x); const settings = settingsOf(stmt.items); - const defaultFlowId = reserveDefaultFlowId(builder, splitId); + const defaultFlowId = + stmt.fail === undefined + ? reserveDefaultFlowId(builder, splitId) + : undefined; builder.flowElements.push({ kind: 'exclusiveGateway', id: splitId, - defaultFlowId, + ...defined({ defaultFlowId }), ...readJobSettings(settings), }); builder.flowElements.push({ @@ -897,12 +855,12 @@ function lowerIf(builder: Builder, stmt: IfStatement, x: string): Frontier { { block: stmt.then, coord: `${x}_t`, - condition: renderExpression(stmt.condition), + condition: flowCondition(stmt), }, ...stmt.elseIfs.map((ei, i) => ({ block: ei.body, coord: `${x}_e${i}`, - condition: renderExpression(ei.condition), + condition: flowCondition(ei), })), ...(stmt.elseBlock !== undefined ? [{ block: stmt.elseBlock, coord: `${x}_e`, flowId: defaultFlowId }] @@ -919,7 +877,7 @@ function lowerIf(builder: Builder, stmt: IfStatement, x: string): Frontier { interface ForkBranch { block: Block; coord: string; - condition?: string; + condition?: FlowCondition; /** Forces the flow's id, marking this branch as the gateway's default. */ flowId?: string; } @@ -936,7 +894,7 @@ function lowerForkBranches( defaultFlowId?: string, ): void { for (const branch of branches) { - const lowered = lowerBlock(builder, branch.block, branch.coord); + const lowered = lowerBlock(builder, branch.block.statements, branch.coord); addFlow( builder, sourceId, @@ -970,8 +928,8 @@ function lowerWhile( ...readJobSettings(settingsOf(stmt.items)), }); - const condition = renderExpression(stmt.condition); - const body = lowerBlock(builder, stmt.body, x); + const condition = flowCondition(stmt); + const body = lowerBlock(builder, stmt.body.statements, x); if (body.entry !== null) { addFlow(builder, loopId, body.entry, condition); @@ -991,8 +949,8 @@ function lowerDoWhile( const loopId = makeGatewayLoopId(x); const defaultFlowId = reserveDefaultFlowId(builder, loopId); - const condition = renderExpression(stmt.condition); - const body = lowerBlock(builder, stmt.body, x); + const condition = flowCondition(stmt); + const body = lowerBlock(builder, stmt.body.statements, x); builder.flowElements.push({ kind: 'exclusiveGateway', @@ -1020,8 +978,9 @@ function lowerDoWhile( * 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. + * (without it the first time no condition holds is a stuck execution, which is + * what `else fail` asks for). For the same reason an `else` alone does not make + * the fork inclusive. */ function lowerParallel( builder: Builder, @@ -1038,18 +997,18 @@ function lowerParallel( const fallback = inclusive && (stmt.branches.some((b) => b.otherwise) || - stmt.branches.every((b) => b.condition !== undefined)); + (stmt.fail === undefined && + stmt.branches.every((b) => b.condition !== undefined))); // An idle claim would rename an authored collider for nothing. - let defaultFlowId: string | undefined; - if (fallback) { - defaultFlowId = reserveDefaultFlowId(builder, forkId); - } + const defaultFlowId = fallback + ? reserveDefaultFlowId(builder, forkId) + : undefined; if (inclusive) { builder.flowElements.push({ kind: 'inclusiveGateway', id: forkId, - ...(defaultFlowId === undefined ? {} : { defaultFlowId }), + ...defined({ defaultFlowId }), ...fork, }); builder.flowElements.push({ @@ -1071,7 +1030,7 @@ function lowerParallel( block: branch.body, coord: `${x}_b${i}`, ...(branch.condition !== undefined - ? { condition: renderExpression(branch.condition) } + ? { condition: flowCondition({ ...branch, condition: branch.condition }) } : {}), ...(i === defaultIndex ? { flowId: defaultFlowId } : {}), })); @@ -1114,7 +1073,7 @@ function lowerRace(builder: Builder, stmt: RaceStatement, x: string): Frontier { }); addFlow(builder, raceId, catchId); - const lowered = lowerBlock(builder, branch.body, coord); + const lowered = lowerBlock(builder, branch.body.statements, coord); addFlow(builder, catchId, lowered.entry ?? joinId); joinContinuation(builder, lowered, joinId); }); @@ -1148,13 +1107,7 @@ function lowerSubProcess( * 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, - stmt: OnHandler, - coord: string, - index: number, -): void { - const x = `${coord}_${index}`; +function lowerOnHandler(builder: Builder, stmt: OnHandler, x: string): void { const id = makeEventSubProcessId(x); const nested = nestedBuilder(builder); @@ -1194,8 +1147,7 @@ function lowerBoundaryHandler( builder: Builder, stmt: OnHandler, hostId: string, - coord: string, - index: number, + x: string, ): void { const id = makeBoundaryEventId(hostId, stmt.trigger, builder.taken); builder.flowElements.push({ @@ -1207,11 +1159,7 @@ function lowerBoundaryHandler( ...readEngineAttributes(stmt), }); - const body = lowerBlockStatements( - builder, - stmt.body.statements, - `${coord}_${index}`, - ); + const body = lowerBlock(builder, stmt.body.statements, x); if (body.entry !== null) { addFlow(builder, id, body.entry); } @@ -1256,37 +1204,28 @@ function handlerEventDefinition(stmt: OnHandler): EventDefinition { case 'timer': case 'condition': return namedTriggerDefinition(trigger, stmt); - case 'escalation': { - const codeVariable = bindingVariable(stmt, 'code'); - const code = raisedCodeOf(stmt.items); + case 'escalation': return { kind: 'escalation', - ...(code !== undefined ? { escalationCode: code } : {}), - ...(codeVariable !== undefined ? { codeVariable } : {}), + ...defined({ + escalationCode: raisedCodeOf(stmt.items), + codeVariable: bindingVariable(stmt, 'code'), + }), }; - } case 'compensation': return { kind: 'compensation' }; case 'cancel': return { kind: 'cancel' }; case 'error': - case undefined: { - const codeVariable = bindingVariable(stmt, 'code'); - const messageVariable = bindingVariable(stmt, 'message'); - const code = raisedCodeOf(stmt.items); + case undefined: return { kind: 'error', - ...(code !== undefined ? { errorCode: code } : {}), - ...(codeVariable !== undefined ? { codeVariable } : {}), - ...(messageVariable !== undefined ? { messageVariable } : {}), + ...defined({ + errorCode: raisedCodeOf(stmt.items), + codeVariable: bindingVariable(stmt, 'code'), + messageVariable: bindingVariable(stmt, 'message'), + }), }; - } - default: { - const exhaustive: never = trigger; - throw new Error( - `astToIr: unhandled handler trigger: ${JSON.stringify(exhaustive)}`, - ); - } } } @@ -1306,10 +1245,9 @@ function bindingVariable(stmt: OnHandler, field: string): string | undefined { function lowerThrow( builder: Builder, stmt: ThrowStatement, - coord: string, - index: number, + x: string, ): Frontier { - const id = stmt.name ?? makeThrowEventId(`${coord}_${index}`); + const id = stmt.name ?? makeThrowEventId(x); const eventDefinition = throwEventDefinition(stmt); builder.flowElements.push({ kind: 'endEvent', @@ -1336,13 +1274,8 @@ function thrownMessageBinding( * A link ends the chain: `BpmnParse.parseSequenceFlow` refuses a flow out of a * link throw. */ -function lowerEmit( - builder: Builder, - stmt: EmitStatement, - coord: string, - index: number, -): Frontier { - const id = stmt.name ?? makeThrowEventId(`${coord}_${index}`); +function lowerEmit(builder: Builder, stmt: EmitStatement, x: string): Frontier { + const id = stmt.name ?? makeThrowEventId(x); const eventDefinition = emitEventDefinition(stmt); builder.flowElements.push({ kind: 'intermediateThrowEvent', @@ -1375,22 +1308,15 @@ function emitEventDefinition(stmt: EmitStatement): EmitEventDefinition { case 'escalation': case undefined: return { kind: 'escalation', escalationCode: code }; - default: { - const exhaustive: never = trigger; - throw new Error( - `astToIr: unhandled emit trigger: ${JSON.stringify(exhaustive)}`, - ); - } } } function lowerIntermediateCatch( builder: Builder, stmt: IntermediateCatchEvent, - coord: string, - index: number, + x: string, ): Frontier { - const id = stmt.name ?? makeIntermediateCatchEventId(`${coord}_${index}`); + const id = stmt.name ?? makeIntermediateCatchEventId(x); builder.flowElements.push({ kind: 'intermediateCatchEvent', id, @@ -1415,12 +1341,6 @@ function throwEventDefinition(stmt: ThrowStatement): EndEventDefinition { case 'error': case undefined: return { kind: 'error', errorCode: code }; - default: { - const exhaustive: never = trigger; - throw new Error( - `astToIr: unhandled throw trigger: ${JSON.stringify(exhaustive)}`, - ); - } } } @@ -1461,36 +1381,26 @@ function namedTriggerDefinition( return { kind: 'signal', signalName: raisedCodeOf(stmt.items) ?? '' }; case 'timer': { const timer = timerPayloadOf(stmt.items); - return { - kind: 'timer', - timerKind: timerParticleKind(timer?.particle), - expression: timer?.time ?? '', - }; + return timerDefinition(timer?.particle, timer?.time, stmt.items); } case 'condition': { const expr = payloadItemOf(stmt.items)?.value; return { kind: 'conditional', condition: expr !== undefined ? renderExpression(expr) : ALWAYS_TRUE, + ...defined({ + conditionDocumentation: valueDocumentation(stmt.items, 'condition'), + }), }; } case 'link': return { kind: 'link', linkName: raisedCodeOf(stmt.items) ?? '' }; - default: { - const exhaustive: never = trigger; - throw new Error( - `astToIr: unhandled named trigger: ${JSON.stringify(exhaustive)}`, - ); - } } } function lowerCallActivity(builder: Builder, stmt: AstCallActivity): Frontier { 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({ @@ -1498,9 +1408,11 @@ function lowerCallActivity(builder: Builder, stmt: AstCallActivity): Frontier { id: stmt.name, ...namedAttrs(stmt), calledElement, - ...(binding !== undefined ? { binding } : {}), - ...(businessKey !== undefined ? { businessKey } : {}), - ...(mapper !== undefined ? { mapper } : {}), + ...defined({ + binding: versionBinding(settings), + businessKey: elAttrValue(settings, 'businessKey'), + mapper: callVariableMapper(settings), + }), ...(inMappings.length > 0 ? { inMappings } : {}), ...(outMappings.length > 0 ? { outMappings } : {}), ...readLoop(stmt), @@ -1614,6 +1526,7 @@ interface RepeatOwner { collection?: Expr; element?: string; completion?: Expr; + completionItems: ParenItem[]; sequential: boolean; items: ParenItem[]; } @@ -1631,17 +1544,36 @@ function readLoop(stmt: RepeatOwner): Repeatable { settingsOf(stmt.items), runSettingKey, ); + const { cardinality, collection, element, completion } = stmt; return { loop: { - ...(stmt.cardinality !== undefined - ? { cardinality: loopCardinality(stmt.cardinality) } + ...(cardinality !== undefined + ? defined({ + cardinality: loopCardinality(cardinality), + cardinalityDocumentation: valueDocumentation(stmt.items, 'count'), + }) : {}), - ...(stmt.collection !== undefined - ? { collection: loopCollection(stmt.collection) } + ...defined({ + collection: + collection === undefined ? undefined : loopCollection(collection), + }), + ...(element !== undefined + ? defined({ + elementVariable: element, + elementVariableDocumentation: valueDocumentation( + stmt.items, + 'each', + ), + }) : {}), - ...(stmt.element !== undefined ? { elementVariable: stmt.element } : {}), - ...(stmt.completion !== undefined - ? { completionCondition: renderExpression(stmt.completion) } + ...(completion !== undefined + ? defined({ + completionCondition: renderExpression(completion), + completionConditionDocumentation: valueDocumentation( + stmt.completionItems, + 'condition', + ), + }) : {}), ...(stmt.sequential ? { sequential: true as const } : {}), ...runSettings, @@ -1657,6 +1589,10 @@ function loopCardinality(expr: Expr): string { if (isLiteralInt(expr)) { return String(expr.value); } + // Text around a template is already the composite expression the engine evaluates. + if (isLiteralString(expr) && EXPRESSION_ANYWHERE.test(expr.value)) { + return expr.value; + } return renderExpression(expr); } @@ -1680,10 +1616,9 @@ interface EngineAttributeOwner { } function readEngineAttributes(owner: EngineAttributeOwner): EngineAttributes { - const executionListeners = readExecutionListeners(owner.listeners); return { ...readJobSettings(settingsOf(owner.items)), - ...(executionListeners === undefined ? {} : { executionListeners }), + ...defined({ executionListeners: readExecutionListeners(owner.listeners) }), }; } @@ -1729,11 +1664,11 @@ function readTaskListeners( binding: listenerBinding(listener), ...(listener.event === 'timeout' ? { - timer: { - kind: 'timer' as const, - timerKind: timerParticleKind(listener.particle), - expression: listener.time ?? '', - }, + timer: timerDefinition( + listener.particle, + listener.time, + listener.items, + ), } : {}), }), @@ -1741,6 +1676,19 @@ function readTaskListeners( return lowered.length > 0 ? lowered : undefined; } +function timerDefinition( + particle: string | undefined, + time: string | undefined, + items: ParenItem[], +): Extract { + return { + kind: 'timer', + timerKind: timerParticleKind(particle), + expression: time ?? '', + ...defined({ expressionDocumentation: valueDocumentation(items, 'timer') }), + }; +} + function listenersFor( listeners: AstListener[], events: readonly E[], @@ -1770,31 +1718,36 @@ function withDeclaredFields< } /** - * A list, map, or script has no `operaton:field` form; the validator reports - * it. + * A list, map, or script has no `operaton:field` or `operaton:property` form; + * the validator reports it. */ -function readFieldInjections(params: AstIoParameter[]): FieldInjection[] { +function textParams( + params: AstIoParameter[], + direction: string, +): { name: string; text: string }[] { return params - .filter((param) => param.direction === FIELD_DIRECTION) + .filter((param) => param.direction === direction) .flatMap((param) => { const value = lowerIoValue(param.value); return value.kind === 'text' - ? [{ name: param.name, value: value.text }] + ? [{ name: param.name, text: value.text }] : []; }); } +function readFieldInjections(params: AstIoParameter[]): FieldInjection[] { + return textParams(params, FIELD_DIRECTION).map(({ name, text }) => ({ + name, + value: text, + })); +} + function readExtensionProperties( params: AstIoParameter[], ): ExtensionProperty[] | undefined { - const properties = params - .filter((param) => param.direction === PROPERTY_DIRECTION) - .flatMap((param) => { - const value = lowerIoValue(param.value); - return value.kind === 'text' - ? [{ key: param.name, value: value.text }] - : []; - }); + const properties = textParams(params, PROPERTY_DIRECTION).map( + ({ name, text }) => ({ key: name, value: text }), + ); return properties.length === 0 ? undefined : properties; } @@ -1802,17 +1755,16 @@ function withExternalExtras( binding: Extract, stmt: AstServiceTask | AstSendTask | AstBusinessRuleTask, ): Extract { - const taskPriority = numericOrElAttrValue( - settingsOf(stmt.items), - TASK_PRIORITY_KEY, - ); - const properties = readExtensionProperties(stmt.params); - const errorMappings = lowerErrorMappings(stmt.errorMappings); return { ...binding, - ...(taskPriority !== undefined ? { taskPriority } : {}), - ...(properties !== undefined ? { properties } : {}), - ...(errorMappings !== undefined ? { errorMappings } : {}), + ...defined({ + taskPriority: numericOrElAttrValue( + settingsOf(stmt.items), + TASK_PRIORITY_KEY, + ), + properties: readExtensionProperties(stmt.params), + errorMappings: lowerErrorMappings(stmt.errorMappings), + }), }; } @@ -1887,16 +1839,34 @@ function boolAttrValue( * same flow id. */ function reserveDefaultFlowId(builder: Builder, gatewayId: string): string { - const id = resolveCollision(makeDefaultFlowId(gatewayId), builder.taken); - builder.taken.add(id); - return id; + return claimId(makeDefaultFlowId(gatewayId), builder.taken); +} + +type FlowCondition = Pick< + IrSequenceFlow, + 'conditionExpression' | 'conditionDocumentation' +>; + +function flowCondition(head: { + condition: Expr; + conditionItems: ParenItem[]; +}): FlowCondition { + return { + conditionExpression: renderExpression(head.condition), + ...defined({ + conditionDocumentation: valueDocumentation( + head.conditionItems, + 'condition', + ), + }), + }; } function addFlow( builder: Builder, sourceRef: string, targetRef: string, - condition?: string, + condition?: FlowCondition, forcedId?: string, ): void { const id = @@ -1904,7 +1874,7 @@ function addFlow( builder.sequenceFlows.push({ id, - ...(condition !== undefined ? { conditionExpression: condition } : {}), + ...condition, sourceRef, targetRef, }); @@ -1942,14 +1912,28 @@ function collectNamedIds(process: Process): Set { type KeyValueAttr = { key: string; value: Expr }; +/** The IR leaves an unset field out rather than holding `undefined`. */ +function defined>( + fields: T, +): { [K in keyof T]?: Exclude } { + return Object.fromEntries( + Object.entries(fields).filter(([, value]) => value !== undefined), + ) as { [K in keyof T]?: Exclude }; +} + function namedAttrs(stmt: { items: ParenItem[] }): Named { const attrs = settingsOf(stmt.items); - const name = attrValue(attrs, 'label'); - const documentation = attrValue(attrs, 'documentation'); - return { - ...(name !== undefined ? { name } : {}), - ...(documentation !== undefined ? { documentation } : {}), - }; + return defined({ + name: attrValue(attrs, 'label'), + documentation: attrValue(attrs, DOCUMENTATION_KEY), + }); +} + +function valueDocumentation( + items: ParenItem[], + value: keyof typeof VALUE_DOCUMENTATION_KEYS, +): string | undefined { + return attrValue(settingsOf(items), VALUE_DOCUMENTATION_KEYS[value]); } function attrValue(attrs: KeyValueAttr[], key: string): string | undefined { @@ -1995,7 +1979,3 @@ function stripExpressionWrapper(rendered: string): string { } return rendered; } - -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 03c1e2e..ab7188b 100644 --- a/packages/transform/src/bpmn-moddle.d.ts +++ b/packages/transform/src/bpmn-moddle.d.ts @@ -39,6 +39,8 @@ declare module 'saxen' { export interface ParseContext { line: number; column: number; + /** The source text of the tag being reported. */ + data: string; } export type OpenTagHandler = ( diff --git a/packages/transform/src/cfg-analysis.ts b/packages/transform/src/cfg-analysis.ts index 2ac7691..5b0d832 100644 --- a/packages/transform/src/cfg-analysis.ts +++ b/packages/transform/src/cfg-analysis.ts @@ -162,18 +162,13 @@ function buildGraph(container: FlowContainer): Graph { 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' && - !hasRealPred; - if (el.kind === 'boundaryEvent' || isLinkCatch) reentries.add(el.id); - if ( - el.kind === 'startEvent' || + const reentry = el.kind === 'boundaryEvent' || - isLinkCatch - ) { - addEdge(VIRTUAL_ENTRY, el.id); - } else if (!hasAnyStart && !hasRealPred) { + (el.kind === 'intermediateCatchEvent' && + el.eventDefinition.kind === 'link' && + !hasRealPred); + if (reentry) reentries.add(el.id); + if (reentry || el.kind === 'startEvent' || (!hasAnyStart && !hasRealPred)) { addEdge(VIRTUAL_ENTRY, el.id); } } @@ -225,17 +220,13 @@ 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; + if (closure([VIRTUAL_EXIT], (n) => pred.get(n) ?? []).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. @@ -282,8 +273,7 @@ function computeIdom( let newIdom: string | undefined; for (const p of pred.get(node) ?? []) { - if (!order.has(p)) continue; // predecessor not reachable from root - if (idom.get(p) === undefined) continue; // not processed yet + if (!order.has(p) || idom.get(p) === undefined) continue; newIdom = newIdom === undefined ? p : intersect(newIdom, p, idom, order); } @@ -381,6 +371,15 @@ function loopEntries( const conditioned = container.sequenceFlows.filter( (f) => f.conditionExpression !== undefined, ); + const plainFlow = (from: string, to: string) => + container.sequenceFlows.some( + (f) => + f.sourceRef === from && + f.targetRef === to && + f.conditionExpression === undefined, + ); + const conditionedOut = (n: string) => + conditioned.filter((f) => f.sourceRef === n).length; const latches = new Map>(); const entered: EnteredLoop[] = []; const jumpCycleHeads = new Set(); @@ -395,14 +394,7 @@ function loopEntries( // 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, - ), - ); + outside(n).some((p) => plainFlow(p, n)); // 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. @@ -448,21 +440,14 @@ function loopEntries( (s) => conditioned.some( (f) => f.sourceRef === s && f.targetRef === head, - ) && conditioned.filter((f) => f.sourceRef === s).length === 1, + ) && conditionedOut(s) === 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, - ), - ); + conditionedOut(head) === 1 && + [...backSources].some((s) => plainFlow(s, head)); const owner = isWhileHead ? head : ownTests.length === 1 diff --git a/packages/transform/src/errors.ts b/packages/transform/src/errors.ts index 6f73ca5..e535dd5 100644 --- a/packages/transform/src/errors.ts +++ b/packages/transform/src/errors.ts @@ -5,6 +5,7 @@ import { BOUNDARY_TRIGGERS, + diagnosticText, EMIT_TRIGGERS, END_TRIGGERS, FORM_CONSTRAINT_NAMES, @@ -15,27 +16,56 @@ import { } from '@bpmn-script/language'; /** + * A message is a headline naming what is wrong and where, then one + * `note: ...` or `help: ...` line per further fact. `detail` fields hold only + * the short cause the headline quotes, so they stay stable keys to group by. + * * 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) { - super(message); + constructor( + headline: string, + lines: readonly string[], + detail: Record, + ) { + super(diagnosticText(headline, ...lines)); this.name = new.target.name; Object.assign(this, detail); } } +/** + * Keeps a quoted value on its headline or note line, and its whitespace + * visible: it is often why the value is reported. + */ +export function oneLine(text: string): string { + return text.replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t'); +} + +function quotedList(words: readonly string[], conjunction: 'and' | 'or') { + return formatPlainWordList( + words.map((word) => `'${word}'`), + conjunction, + ); +} + 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 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', + `'operaton:type="mail"' or 'operaton:type="shell"' with their 'operaton:field' children`, ], - ['a decision reference on a business rule task', 'operaton:decisionRef'], + ['a decision reference on a business rule task', "'operaton:decisionRef'"], ] as const; export const SERVICE_TASK_FORM_ATTRIBUTES = formatPlainWordList( @@ -43,6 +73,26 @@ export const SERVICE_TASK_FORM_ATTRIBUTES = formatPlainWordList( 'and', ); +function formLines( + forms: readonly (readonly [prose: string, attributes: string])[], +): readonly string[] { + return [ + `note: the supported forms are ${formatPlainWordList(forms.map(([prose]) => prose))}`, + `note: their attributes are ${formatPlainWordList( + forms.map(([, attributes]) => attributes), + 'and', + )}`, + ]; +} + +/** Closes a refusal of the execution form itself, not of a setting beside it. */ +export const SERVICE_TASK_FORM_LINES = formLines(SERVICE_TASK_FORMS); + +/** A thrown message takes the code and external forms, the first four rows. */ +export const THROWN_MESSAGE_FORM_LINES = formLines( + SERVICE_TASK_FORMS.slice(0, 4), +); + /** * Operaton applies the same mandatory-discriminator rule to every tag its * service-task factory runs, so `subject` names which one refused. @@ -56,10 +106,11 @@ export class UnsupportedServiceTaskFormError extends UnsupportedConstructError { serviceTaskId: string, construct: string, subject = 'Service task', + ...notes: string[] ) { super( - `${subject} '${serviceTaskId}' uses unsupported execution form: ${construct}. ` + - `Supported forms are ${formatPlainWordList(SERVICE_TASK_FORMS.map(([prose]) => prose))}.`, + `${subject.toLowerCase()} '${serviceTaskId}' uses an unsupported execution form: ${construct}`, + notes, { serviceTaskId, construct, subject }, ); } @@ -72,19 +123,26 @@ export class UnsupportedFormFieldTypeError extends UnsupportedConstructError { constructor(elementId: string, fieldId: string, fieldType: string) { super( - `The form field '${fieldId}' on '${elementId}' has type '${fieldType}', ` + - 'which this tool cannot import. Supported form field types are ' + - 'string, long, boolean, date, and enum.', + `form field '${fieldId}' on '${elementId}' has the unsupported type '${fieldType}'`, + [ + `note: the supported form field types are ${quotedList(['string', 'long', 'boolean', 'date', 'enum'], 'and')}`, + ], { elementId, fieldId, fieldType }, ); } } -const REGISTERED_VALIDATOR_NAMES = formatPlainWordList( +const REGISTERED_VALIDATOR_NAMES = quotedList( FORM_CONSTRAINT_NAMES.filter((name) => name !== 'validator'), 'and', ); +/** Closes a refusal of the constraint's name, not of its config or count. */ +export const FORM_CONSTRAINT_NAME_LINES: readonly string[] = [ + `note: Operaton registers ${REGISTERED_VALIDATOR_NAMES}, and reads a custom validator class or expression off 'validator'`, + "note: 'FormValidators.createValidator' fails the deployment on any other name", +]; + export class UnsupportedFormFieldConstraintError extends UnsupportedConstructError { declare readonly elementId: string; declare readonly fieldId: string; @@ -96,25 +154,22 @@ export class UnsupportedFormFieldConstraintError extends UnsupportedConstructErr fieldId: string, constraintName: string, detail: string, + ...notes: string[] ) { super( - `The constraint '${constraintName}' on form field '${fieldId}' of '${elementId}' ` + - `cannot be imported: ${detail}. Operaton registers ` + - `${REGISTERED_VALIDATOR_NAMES}, and reads a custom validator class ` + - "or expression off 'validator'; FormValidators.createValidator " + - 'fails the deployment on any other name.', + `constraint '${constraintName}' on form field '${fieldId}' of '${elementId}' cannot be imported: ${detail}`, + notes, { elementId, fieldId, constraintName, detail }, ); } } 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.'; + 'note: BPMNscript imports start and 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'; export class UnsupportedElementError extends UnsupportedConstructError { declare readonly qname: string; @@ -122,26 +177,55 @@ export class UnsupportedElementError extends UnsupportedConstructError { constructor(qname: string, elementId?: string) { super( - `The BPMN element ${qname}` + - (elementId ? ` (id='${elementId}')` : '') + - ` is a kind that this tool cannot import. ${SUPPORTED_KINDS_MESSAGE}`, + (elementId ? `element '${elementId}' ` : 'an element ') + + `of the kind '${qname}' cannot be imported`, + [SUPPORTED_KINDS_MESSAGE], { qname, elementId }, ); } } +export const PLAIN_NAME_NOTE = + "note: a plain name is letters, digits and '_', with '-' between them, and no keyword"; + +/** + * The engine reads and writes a variable under its exact name, and the script + * writes one only as a plain name, with no backtick form. + */ +export class UnsupportedVariableNameError extends UnsupportedConstructError { + declare readonly elementId: string; + declare readonly attribute: string; + declare readonly variableName: string; + declare readonly detail: string; + + constructor(elementId: string, attribute: string, variableName: string) { + const detail = `${attribute} names the variable '${oneLine(variableName)}', which BPMNscript cannot write back unchanged`; + super( + `element '${elementId}' cannot be imported: ${detail}`, + [ + 'note: BPMNscript writes a variable as a plain name, and this name is not one', + PLAIN_NAME_NOTE, + ], + { elementId, attribute, variableName, detail }, + ); + } +} + export class UnsupportedCallActivityError extends UnsupportedConstructError { declare readonly elementId: string; declare readonly detail: string; - constructor(elementId: string, detail: string) { + constructor(elementId: string, detail: string, ...notes: string[]) { super( - `The call activity '${elementId}' cannot be imported: ${detail}. ` + - 'Supported call activities name a calledElement, an optional ' + - 'latest/deployment/version binding, a businessKey, a ' + - 'variableMappingClass or variableMappingDelegateExpression, and ' + - 'in/out mappings using source+target, sourceExpression+target, or ' + - 'variables="all".', + `call activity '${elementId}' cannot be imported: ${detail}`, + [ + ...notes, + "note: a supported call activity names a 'calledElement', an optional " + + "'latest', 'deployment' or 'version' binding, a 'businessKey', a " + + "'variableMappingClass' or 'variableMappingDelegateExpression', and " + + "in and out mappings using 'source' with 'target', " + + `'sourceExpression' with 'target', or 'variables="all"'`, + ], { elementId, detail }, ); } @@ -160,9 +244,9 @@ export class UnsupportedEventDefinitionError extends UnsupportedConstructError { definitionType: string, ) { super( - `The ${eventKind} event '${elementId}' carries a ${friendlyEventDefinition(definitionType)} ` + - `definition (${definitionType}) that this tool cannot import. ` + - supportedKindsMessage(eventKind), + `${eventKind} event '${elementId}' carries an unsupported ` + + `${friendlyEventDefinition(definitionType)} definition '${definitionType}'`, + supportedKindsLines(eventKind), { elementId, eventKind, definitionType }, ); } @@ -181,24 +265,20 @@ const END_EVENT_TRIGGERS = [ ...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.'; +export const HANDLER_TRIGGER_NOTE = `note: an event handler catches one ${quotedList(HANDLER_START_TRIGGERS, 'or')} trigger on its single start event`; + +export const THROW_CODE_NOTE = + 'note: throws and emits carry the code or name their kind requires, and compensation carries neither'; export class UnsupportedEventFeatureError extends UnsupportedConstructError { declare readonly elementId: string; declare readonly detail: string; - constructor( - elementId: string, - detail: string, - remedy: string = EVENT_SURFACE_NOTE, - ) { - super( - `The element '${elementId}' cannot be imported: ${detail}. ${remedy}`, - { elementId, detail }, - ); + constructor(elementId: string, detail: string, ...lines: string[]) { + super(`element '${elementId}' cannot be imported: ${detail}`, lines, { + elementId, + detail, + }); } } @@ -207,8 +287,13 @@ export class UnsupportedLoopCharacteristicsError extends UnsupportedConstructErr declare readonly loopType: string; declare readonly detail: string; - constructor(elementId: string, loopType: string, detail: string) { - super(`The repetition on '${elementId}' cannot be imported: ${detail}.`, { + constructor( + elementId: string, + loopType: string, + detail: string, + ...notes: string[] + ) { + super(`repetition on '${elementId}' cannot be imported: ${detail}`, notes, { elementId, loopType, detail, @@ -225,8 +310,11 @@ export class UnsupportedCollaborationError extends UnsupportedConstructError { constructor(detail: string) { super( - `The file contains ${detail}; this tool imports one process, and ` + - '`BpmnParse.parseProcessDefinitions` deploys each of them.', + `the file contains ${detail}`, + [ + 'note: BPMNscript imports one process', + "note: 'BpmnParse.parseProcessDefinitions' deploys each of them", + ], { detail }, ); } @@ -235,8 +323,8 @@ export class UnsupportedCollaborationError extends UnsupportedConstructError { export class UnsupportedDocumentError extends UnsupportedConstructError { declare readonly detail: string; - constructor(detail: string) { - super(`The document cannot be imported: ${detail}.`, { detail }); + constructor(detail: string, ...notes: string[]) { + super(`the document cannot be imported: ${detail}`, notes, { detail }); } } @@ -244,17 +332,10 @@ export class UnsupportedExtensionFormError extends UnsupportedConstructError { declare readonly elementId: string; declare readonly detail: string; - constructor(elementId: string, detail: string) { + constructor(elementId: string, detail: string, ...notes: string[]) { super( - `The Operaton extension content on '${elementId}' cannot be imported: ${detail}. ` + - 'An input/output parameter or map entry carries body text or exactly ' + - 'one nested value, never both or two, and is always named; a script ' + - 'value declares a scriptFormat and carries inline code, never an ' + - 'external resource; a listener names exactly one binding and an event ' + - 'its position accepts; a timeout task listener carries exactly one ' + - 'timer, which no other task listener event carries; and an injected ' + - 'field names exactly one value slot, Operaton refusing to deploy one ' + - 'that names none or both of its literal slots.', + `the Operaton extension content on '${elementId}' cannot be imported: ${detail}`, + notes, { elementId, detail }, ); } @@ -264,12 +345,10 @@ export class UnsupportedFormReferenceError extends UnsupportedConstructError { declare readonly elementId: string; declare readonly detail: string; - constructor(elementId: string, detail: string) { + constructor(elementId: string, detail: string, ...notes: string[]) { super( - `The form reference on '${elementId}' cannot be imported: ${detail}. ` + - 'Operaton refuses to deploy a user task that names both a form key ' + - 'and a form reference, and one whose form reference has no binding ' + - 'or a binding outside latest, deployment, and version.', + `form reference on '${elementId}' cannot be imported: ${detail}`, + notes, { elementId, detail }, ); } @@ -283,29 +362,36 @@ export class UnsupportedConditionExpressionError extends UnsupportedConstructErr declare readonly elementId: string; declare readonly detail: string; - constructor(elementId: string, detail: string) { + constructor(elementId: string, detail: string, ...notes: string[]) { super( - `The condition on '${elementId}' cannot be imported: ${detail}. This ` + - '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.', + `condition on '${elementId}' cannot be imported: ${detail}`, + [ + ...notes, + "note: BPMNscript writes a condition as a 'tFormalExpression', which " + + 'Operaton evaluates as UEL', + 'note: a condition of another type, or one Operaton hands to a script ' + + 'engine, has no spelling in BPMNscript', + ], { elementId, detail }, ); } } -/** `detail` is the engine's own sentence, so the refusal reads as the deployment error would. */ +/** `detail` is the engine's own wording, 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'.", + `exclusive gateway '${elementId}' cannot be imported`, + [ + "note: Operaton's 'BpmnParse.validateExclusiveGateway' fails the " + + `deployment with "${detail}"`, + 'note: no running process exists to write back', + 'help: give every route but the fallback a condition, and name the ' + + "fallback in the gateway's 'default'", + ], { elementId, detail }, ); } @@ -315,12 +401,10 @@ export class UnsupportedErrorMappingError extends UnsupportedConstructError { declare readonly elementId: string; declare readonly detail: string; - constructor(elementId: string, detail: string) { + constructor(elementId: string, detail: string, ...notes: string[]) { super( - `The error mapping on '${elementId}' cannot be imported: ${detail}. ` + - "Operaton's parseOperatonErrorEventDefinitions fails the deployment " + - 'on an operaton:errorEventDefinition carrying no expression, and ' + - 'this tool needs its errorRef to name an error root carrying a code.', + `error mapping on '${elementId}' cannot be imported: ${detail}`, + notes, { elementId, detail }, ); } @@ -330,15 +414,11 @@ export class UnsupportedAssignmentError extends UnsupportedConstructError { declare readonly elementId: string; declare readonly detail: string; - constructor(elementId: string, detail: string) { - super( - `The assignment on '${elementId}' cannot be imported: ${detail}. ` + - 'Operaton refuses to deploy a user task carrying a ' + - 'bpmn:humanPerformer beside operaton:assignee ' + - '(BpmnParse.parseUserTaskCustomExtensions) or more than one ' + - 'bpmn:humanPerformer (parseHumanPerformer).', - { elementId, detail }, - ); + constructor(elementId: string, detail: string, ...notes: string[]) { + super(`assignment on '${elementId}' cannot be imported: ${detail}`, notes, { + elementId, + detail, + }); } } @@ -347,26 +427,26 @@ function friendlyEventDefinition(definitionType: string): string { return local.replace(/EventDefinition$/, '').toLowerCase() || 'special'; } -function supportedKindsMessage(eventKind: EventPosition): string { +function supportedKindsLines(eventKind: EventPosition): string[] { switch (eventKind) { case 'start': - return ( - "A plain start event carries no definition; a process's start " + - `supports ${formatPlainWordList(START_TRIGGERS)}, and an event ` + - `handler's start supports ${formatPlainWordList(HANDLER_START_TRIGGERS)}.` - ); + return [ + 'note: a plain start event carries no definition', + `note: a process's start supports ${quotedList(START_TRIGGERS, 'or')}`, + `note: an event handler's start supports ${quotedList(HANDLER_START_TRIGGERS, 'or')}`, + ]; case 'end': - return ( - `A typed end event supports ${formatPlainWordList(END_EVENT_TRIGGERS)}, ` + - 'plus cancel inside a block that can be given up.' - ); + return [ + `note: a typed end event supports ${quotedList(END_EVENT_TRIGGERS, 'or')}, ` + + "plus 'cancel' inside a block that can be given up", + ]; case 'intermediate throw': - return `An emit supports ${formatPlainWordList(EMIT_TRIGGERS)}.`; + return [`note: an emit supports ${quotedList(EMIT_TRIGGERS, 'or')}`]; case 'boundary': - return ( - `A boundary event supports ${formatPlainWordList(BOUNDARY_TRIGGERS_BUT_CANCEL)}, ` + - 'plus cancel on a block that can be given up.' - ); + return [ + `note: a boundary event supports ${quotedList(BOUNDARY_TRIGGERS_BUT_CANCEL, 'or')}, ` + + "plus 'cancel' on a block that can be given up", + ]; } } @@ -379,7 +459,7 @@ export class LayoutError extends Error { 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}`); + 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 index 6cd09c1..0966833 100644 --- a/packages/transform/src/host-messages.ts +++ b/packages/transform/src/host-messages.ts @@ -19,8 +19,12 @@ function describeRoot(xml: string): string { 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})`; + return [ + '', + `note: the root element is '<${tag}>'`, + `note: BPMN expects '' in the namespace '${BPMN_NAMESPACE}'`, + ...(uri === undefined ? [] : [`note: the file declares '${decl}=${uri}'`]), + ].join('\n'); } function printablePreview(text: string): string { @@ -40,7 +44,7 @@ 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 `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 bf05a12..2164719 100644 --- a/packages/transform/src/humanize.ts +++ b/packages/transform/src/humanize.ts @@ -1,6 +1,7 @@ /** - * 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. + * Derives a BPMN `name` from a DSL id. The export writes it on a task or + * sub-process without a label and `xmlToIr` drops a `name` equal to it there, + * 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 69729c6..dcce9e8 100644 --- a/packages/transform/src/index.ts +++ b/packages/transform/src/index.ts @@ -67,12 +67,10 @@ export { export { parseJuel, renderRawFallback } from './juel.js'; export type { JuelNode } from './juel.js'; -export { irToXml } from './ir-to-xml.js'; +export { HISTORY_TIME_TO_LIVE, irToXml } from './ir-to-xml.js'; export { - COMPENSATION_BOUNDARY_DETAIL, CONNECTOR_CONSTRUCT, dataConstructDropMessage, - IS_FOR_COMPENSATION_DETAIL, loopDroppedMessage, manualTaskMessage, xmlToIr, @@ -89,6 +87,7 @@ export { UnsupportedCollaborationError, UnsupportedDocumentError, UnsupportedCallActivityError, + UnsupportedVariableNameError, UnsupportedExtensionFormError, UnsupportedFormReferenceError, UnsupportedConditionExpressionError, @@ -101,7 +100,11 @@ export { } from './errors.js'; export { astToIr } from './ast-to-ir.js'; -export { irToDsl, UNSTRUCTURED_MARKER } from './ir-to-dsl.js'; +export { + irToDsl, + isSynthesizedTerminalId, + UNSTRUCTURED_MARKER, +} from './ir-to-dsl.js'; export type { PrintWarning, PrintWarningCategory } from './ir-to-dsl.js'; export { EMPTY_INPUT_MESSAGE, diff --git a/packages/transform/src/ir-to-dsl.ts b/packages/transform/src/ir-to-dsl.ts index 8f1352c..6fff912 100644 --- a/packages/transform/src/ir-to-dsl.ts +++ b/packages/transform/src/ir-to-dsl.ts @@ -8,6 +8,8 @@ import { CALL_MAPPER_KEY_BY_KIND, DATE_PATTERN_KEY, + DOCUMENTATION_KEY, + ELSE_FAIL_WORD, END_TRIGGERS, ENGINE_KEYS, type EngineKey, @@ -17,9 +19,12 @@ import { EXTERNAL_BINDING_KEY, EXTERNAL_TASK_EL_NAME, FIELD_DIRECTION, + formatPlainWordList, ID_TEXT, + isPlainName, INPUT_DIRECTION, isReservedName, + printName, joinSettingKey, LOOP_VARIABLES, OUTPUT_DIRECTION, @@ -27,10 +32,12 @@ import { STARTABLE_KEY, PROPERTY_DIRECTION, runSettingKey, + singleTemplateBody, TASK_PRIORITY_KEY, TIMER_PARTICLE_BY_KIND, TYPE_BINDING_KEY, USER_TASK_VERBATIM_KEYS, + VALUE_DOCUMENTATION_KEYS, } from '@bpmn-script/language'; import type { BpmnProcess, @@ -71,13 +78,14 @@ import { boundaryEventIdBase, CATCH_EVENT_PREFIX, claimDeclarationName, + claimId, isMintedEndId, isMintedStartId, - isWritableName, - mintPrintableName, - resolveCollision, + makeSequenceFlowId, THROW_EVENT_PREFIX, } from './synthesize-ids.js'; +import { oneLine } from './errors.js'; +import { HISTORY_TIME_TO_LIVE } from './ir-to-xml.js'; import { analyzeCfg, type CfgAnalysis, closure } from './cfg-analysis.js'; import { escapeQuoted, @@ -94,14 +102,10 @@ export const INDENT = ' '; */ type Lines = string[]; +/** Each code's name as printed, keyed by code. */ 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; @@ -114,31 +118,33 @@ export type PrintWarningCategory = | 'degradedSplit' | 'droppedCondition' | 'refusedStatement' - | 'renamedId' - | 'droppedSetting'; - -/** - * The id stays out of `message`: a synthesized id spells BPMN vocabulary the - * surface hides from readers. - */ + | 'droppedSetting' + | 'skippedJoin' + | 'addedJoin' + | 'changedJoin' + | 'nestedFork' + | 'unreachable'; + +/** `message` follows the same headline-and-notes shape as an import warning. */ export interface PrintWarning { elementId: string; category: PrintWarningCategory; message: string; } -export function irToDsl(process: BpmnProcess): { +export function irToDsl(model: BpmnProcess): { source: string; warnings: PrintWarning[]; } { const warnings: PrintWarning[] = []; + const process = withNestedForks( + withoutUnreachable(model, warnings), + warnings, + ); const codes = codeDeclarations(process); - const names: PrintNames = { - ...codes.names, - printed: printedNames(process, warnings), - }; + const names = codes.names; warnGatewayText(process, warnings); - warnRefusedStatements(process, names, warnings); + warnRefusedStatements(process, warnings); const emitter = new Emitter(process, warnings, names); const body = emitter.emit(); @@ -153,6 +159,224 @@ export function irToDsl(process: BpmnProcess): { return { source: lines.join('\n') + '\n', warnings }; } +/** + * Operaton deploys an element nothing leads to and never runs it, but every + * statement of a script has a way in, so it would draw "this step can never + * run". Where a container has no start event, every element without a route in + * is an entry, as the printer treats it. + */ +function withoutUnreachable( + container: T, + warnings: PrintWarning[], +): T { + const { next, lead } = flowGraph( + container.flowElements, + container.sequenceFlows, + ); + const linkName = (el: FlowElement): string | undefined => + 'eventDefinition' in el && el.eventDefinition?.kind === 'link' + ? el.eventDefinition.linkName + : undefined; + for (const el of container.flowElements) { + if (el.kind !== 'intermediateThrowEvent' || linkName(el) === undefined) { + continue; + } + for (const other of container.flowElements) { + if ( + other.kind === 'intermediateCatchEvent' && + linkName(other) === linkName(el) + ) { + lead(el.id, other.id); + } + } + } + const hasStart = container.flowElements.some( + (el) => el.kind === 'startEvent', + ); + const entries = container.flowElements + .filter( + (el) => + el.kind === 'startEvent' || + isHandler(el) || + (!hasStart && + !isBoundary(el) && + !container.sequenceFlows.some((f) => f.targetRef === el.id)), + ) + .map((el) => el.id); + const live = closure(entries, (n) => next.get(n) ?? []); + const dead = new Set( + container.flowElements.map((el) => el.id).filter((id) => !live.has(id)), + ); + for (const id of dead) warnings.push(unreachableWarning(id)); + return { + ...container, + flowElements: container.flowElements + .filter((el) => !dead.has(el.id)) + .map((el) => + el.kind === 'subProcess' ? withoutUnreachable(el, warnings) : el, + ), + sequenceFlows: container.sequenceFlows.filter( + (f) => !dead.has(f.sourceRef) && !dead.has(f.targetRef), + ), + }; +} + +function unreachableWarning(elementId: string): PrintWarning { + return { + elementId, + category: 'unreachable', + message: + `'${elementId}' was left out of the script, since no route reaches it\n` + + 'note: no start event, boundary event or event handler leads to it, so the engine never runs it\n' + + 'note: the script has no form for a step nothing leads to\n' + + 'note: a start-before-activity on its id finds nothing in the rebuilt process', + }; +} + +/** + * A parallel fork starts every route at once, so routes meeting at a join of + * their own before the fork's own join can leave from a fork of their own + * without changing what runs. That gives each join a block of its own to close: + * the script has no form for a join that takes only some of a block's + * branches. + */ +function withNestedForks( + container: T, + warnings: PrintWarning[], +): T { + const elements = [...container.flowElements]; + const origin = new Map(); + let flows = container.sequenceFlows; + const taken = new Set([...elements, ...flows].map((x) => x.id)); + for (let nested = true; nested;) { + nested = false; + for (const fork of elements) { + if (fork.kind !== 'parallelGateway') continue; + const outs = flows.filter((f) => f.sourceRef === fork.id); + const group = ownJoinGroup(fork.id, outs, elements, flows); + if (group === undefined) continue; + const inner = claimId(`${fork.id}_fork`, taken); + const entry = makeSequenceFlowId(fork.id, inner, taken); + const original = origin.get(fork.id) ?? fork.id; + if (!origin.has(fork.id) && ![...origin.values()].includes(original)) { + warnings.push(nestedForkWarning(original)); + } + origin.set(inner, original); + elements.splice(elements.indexOf(fork) + 1, 0, { + kind: 'parallelGateway', + id: inner, + }); + flows = flows.flatMap((f) => { + if (!group.includes(f)) return [f]; + const moved = { ...f, sourceRef: inner }; + return f === group[0] + ? [{ id: entry, sourceRef: fork.id, targetRef: inner }, moved] + : [moved]; + }); + nested = true; + break; + } + } + return { + ...container, + flowElements: elements.map((el) => + el.kind === 'subProcess' ? withNestedForks(el, warnings) : el, + ), + sequenceFlows: flows, + }; +} + +function nestedForkWarning(forkId: string): PrintWarning { + return { + elementId: forkId, + category: 'nestedFork', + message: + `gateway '${forkId}' was printed as nested 'parallel' blocks\n` + + "note: the script has no form for a join that takes only some of a block's branches\n" + + 'note: the rebuilt process adds a fork ahead of the routes each such join takes\n' + + 'note: every route still starts at once and each join waits for the same routes, so the process runs the same', + }; +} + +/** + * The routes out of `forkId` that all run into one parallel join and nowhere + * else, when some other route runs into a different join. A join whose every + * route in starts inside one route's region belongs to that route. + */ +function ownJoinGroup( + forkId: string, + outs: readonly SequenceFlow[], + elements: readonly FlowElement[], + flows: readonly SequenceFlow[], +): SequenceFlow[] | undefined { + if (outs.length < 3) return undefined; + const { next, prev } = flowGraph(elements, flows); + const joins = new Set( + elements + .filter( + (el) => + el.kind === 'parallelGateway' && (prev.get(el.id)?.length ?? 0) > 1, + ) + .map((el) => el.id), + ); + const routes = outs.map((f) => { + const region = new Set([f.targetRef]); + const exits = new Set(); + for (let grew = true; grew;) { + grew = false; + exits.clear(); + for (const n of region) { + for (const m of next.get(n) ?? []) { + if (region.has(m) || m === forkId) continue; + if (joins.has(m) && !prev.get(m)!.every((p) => region.has(p))) { + exits.add(m); + } else { + region.add(m); + grew = true; + } + } + } + } + return { f, region, exits: [...exits] }; + }); + const joinedAlone = (join: string) => + routes.filter((r) => r.exits.length === 1 && r.exits[0] === join); + for (const join of new Set(routes.flatMap((r) => r.exits))) { + const group = joinedAlone(join); + if ( + group.length >= 2 && + group.length < routes.length && + prev.get(join)!.every((p) => group.some((r) => r.region.has(p))) && + routes.some((r) => r.exits.some((e) => e !== join)) + ) { + return group.map((r) => r.f); + } + } + return undefined; +} + +/** Flows plus the hop from each boundary event's host into it. */ +function flowGraph( + elements: readonly FlowElement[], + flows: readonly SequenceFlow[], +) { + const next = new Map(); + const prev = new Map(); + const lead = (from: string, to: string): void => { + next.set(from, [...(next.get(from) ?? []), to]); + prev.set(to, [...(prev.get(to) ?? []), from]); + }; + for (const f of flows) lead(f.sourceRef, f.targetRef); + for (const el of elements) if (isBoundary(el)) lead(el.attachedToRef, el.id); + return { next, prev, lead }; +} + +const GATEWAY_HAS_NO_STATEMENT = [ + 'note: the script derives every split and every merge from its block ' + + 'structure, so no statement here carries it', + 'note: the process runs the same without it', +]; + /** * Gateways only: `xmlToIr` reports every other elided label, so a wider rule * would report it twice. @@ -171,22 +395,20 @@ function warnGatewayText( warnings.push({ elementId: el.id, category: 'label', - message: - `The label '${el.name}' was not written to the script: the ` + - 'script derives every split and every merge from its block ' + - 'structure, so there is no statement here to carry a name. The ' + - 'process runs the same without it.', + message: [ + `label '${oneLine(el.name)}' on gateway '${el.id}' was not written to the script`, + ...GATEWAY_HAS_NO_STATEMENT, + ].join('\n'), }); } if (el.documentation !== undefined) { warnings.push({ elementId: el.id, category: 'documentation', - message: - 'The documentation written here was not written to the script: ' + - 'the script derives every split and every merge from its block ' + - 'structure, so there is no statement here to carry it. The ' + - 'process runs the same without it.', + message: [ + `documentation on gateway '${el.id}' was not written to the script`, + ...GATEWAY_HAS_NO_STATEMENT, + ].join('\n'), }); } } @@ -198,19 +420,18 @@ function warnGatewayText( */ function warnRefusedStatements( container: FlowContainer, - names: PrintNames, warnings: PrintWarning[], startTriggerSuppressed = false, ): void { for (const el of container.flowElements) { if ( !isElidedOnPrint(el, container, startTriggerSuppressed) && - isReservedName(nameOf(names, el.id), container.id) + isReservedName(el.id, container.id) ) { warnings.push(reservedNameWarning(el.id)); } if (el.kind === 'subProcess') { - warnRefusedStatements(el, names, warnings, el.triggeredByEvent === true); + warnRefusedStatements(el, warnings, el.triggeredByEvent === true); } } } @@ -220,53 +441,10 @@ function reservedNameWarning(elementId: string): PrintWarning { elementId, 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.', - }; -} - -/** - * 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); - } + `step '${elementId}' has a name the script keeps for the names it derives itself\n` + + 'note: the printed script draws an error there when it is read back\n' + + 'help: rename the step in the model and print it again', }; - 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 { @@ -312,7 +490,11 @@ class Emitter { */ private readonly elidedTail: string | undefined; private readonly linkedCatch: ReadonlyMap; + private readonly adjoinedHops = new Set(); + private readonly skippedJoins = new Set(); private readonly ownMerges: ReadonlyMap; + /** The back edges that close a do-while on their gateway's test. */ + private readonly doWhileTests: ReadonlySet; constructor( private readonly container: FlowContainer, @@ -346,6 +528,9 @@ class Emitter { // 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.doWhileTests = new Set( + this.cfg.backEdges().filter((f) => this.isDoWhileTest(f)), + ); this.ownMerges = this.pairMerges(); } @@ -359,7 +544,7 @@ class Emitter { private pairMerges(): Map { const loopEnds = new Set( this.cfg.backEdges().flatMap((f) => { - if (this.isDoWhileTest(f)) return [f.sourceRef]; + if (this.doWhileTests.has(f)) return [f.sourceRef]; const isWhileHead = f.conditionExpression === undefined && this.successors(f.targetRef).length > 1; @@ -448,8 +633,7 @@ class Emitter { /** * 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}). + * `stop`. */ private ownJoin( splitId: string, @@ -464,11 +648,7 @@ class Emitter { (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), - ) + !outs.some((f) => this.reachable(f.targetRef, splitId, stop).has(merge)) ) { return undefined; } @@ -519,66 +699,28 @@ class Emitter { 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. + // 2. Every entry-reached chain pass 1 left for a jump. 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.`, - ); - } - } + this.sweepChains(owned, lines); // 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)) { - 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 }); - } + const boundaries = this.container.flowElements.flatMap((el, index) => { + if (!isBoundary(el) || this.emittedNodes.has(el.id)) return []; + const block: string[] = []; + this.emitBoundaryHandler(el, block, 0); + const base = boundaryEventIdBase( + el.attachedToRef, + renderTrigger(el.eventDefinition, this.names).head, + ); + return [{ index, base, rank: boundaryIdRank(el.id, base), block }]; }); + const boundaryBlocks = new Map(boundaries.map((b) => [b.index, b.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; + for (const group of Map.groupBy(boundaries, (b) => b.base).values()) { 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) => @@ -588,12 +730,14 @@ class Emitter { } // 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)) { - this.emitFrom(el.id, undefined, lines, 0); - } - } + this.sweepChains( + new Set( + this.container.flowElements + .filter((el) => !isHandler(el) && !isBoundary(el)) + .map((el) => el.id), + ), + lines, + ); // 5. Every route left becomes a jump; `consume` reports a dropped // condition. @@ -654,6 +798,52 @@ class Emitter { return lines; } + /** + * Prints every unprinted node of `pool`. Each walk starts at a chain head + * (every predecessor in `pool` printed), else in a cycle at a node a printed + * one flows into; model order breaks ties. Started mid-chain, a walk would + * print the tail first and need a `goto` into it, which a synthesized throw + * or catch cannot take. + */ + private sweepChains(pool: ReadonlySet, lines: string[]): void { + const leftover = () => + this.container.flowElements.filter( + (el) => pool.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) => !pool.has(p) || this.emittedNodes.has(p)), + ) ?? + rest.find((el) => preds(el).some((p) => this.emittedNodes.has(p))) ?? + rest[0]!; + if (this.settlesAsJump(head.id)) continue; + 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.`, + ); + } + } + } + + /** + * A `goto` never names a gateway, so every route into a chain head that is + * one prints as a jump forwarded through it. When that forward lands on a + * printed node, walking the head would only add a jump nothing reaches. + */ + private settlesAsJump(id: string): boolean { + if (!this.isGatewayId(id)) return false; + const real = this.forwardToRealTarget(id, new Set()); + if (real === undefined || !this.emittedNodes.has(real)) return false; + this.emittedNodes.add(id); + for (const f of this.routesOut(id)) this.consumedFlows.add(f.id); + return true; + } + private emitStartGroup( start: Extract, lines: string[], @@ -694,17 +884,15 @@ class Emitter { } /** - * A link hop counts only for throws in `hops`, whose catch this walk already - * printed adjoining; unlike {@link reachable}, other hops are not crossed. + * A link hop counts only for throws in `adjoinedHops`, whose catch already + * printed right behind them; unlike {@link reachable}, other hops are not + * crossed. */ - private flowsOnTo( - from: string, - to: string, - hops: ReadonlySet, - ): boolean { + private flowsOnTo(from: string, to: string): boolean { return closure( [from], - (n) => (hops.has(n) ? this.successors(n) : this.modelSuccessors(n)), + (n) => + this.adjoinedHops.has(n) ? this.successors(n) : this.modelSuccessors(n), to, ).has(to); } @@ -749,6 +937,15 @@ class Emitter { return el !== undefined && isGateway(el); } + /** A join that waits for more than one route before it lets the run on. */ + private isSynchronizing(id: string): boolean { + const kind = this.byId.get(id)?.kind; + return ( + (kind === 'parallelGateway' || kind === 'inclusiveGateway') && + this.cfg.incoming(id).length > 1 + ); + } + /** 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; @@ -793,6 +990,10 @@ class Emitter { return jobSettingItems(el, joinSettingKey); } + private blockSettings(splitId: string, join: string | undefined): string[] { + return [...this.takeHeadSettings(splitId), ...this.takeJoinSettings(join)]; + } + private unconsumed(flows: readonly SequenceFlow[]): SequenceFlow[] { return flows.filter((f) => !this.consumedFlows.has(f.id)); } @@ -806,15 +1007,11 @@ class Emitter { lines: string[], ): void { this.emittedNodes.add(handler.id); - lines.push(...buildOnHeader(handler, this.names)); - for (const l of new Emitter( - handler, - this.warnings, - this.names, - true, - ).emit()) - lines.push(INDENT + l); - lines.push('}'); + pushBlock( + lines, + buildOnHeader(handler, this.names), + new Emitter(handler, this.warnings, this.names, true).emit(), + ); } /** @@ -827,12 +1024,10 @@ class Emitter { depth: number, ): void { this.emittedNodes.add(boundary.id); - 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); - for (const l of body) lines.push(INDENT + l); - lines.push('}'); + pushBlock(lines, buildBoundaryHeader(boundary, this.names), body); } private emitFrom( @@ -846,7 +1041,6 @@ class Emitter { return; } let current = node; - const hops = new Set(); let guard = this.byId.size + 1; while (current !== undefined && current !== stop && guard-- > 0) { // A start whose step reaches the elided end prints right before that @@ -862,7 +1056,7 @@ class Emitter { const flowsOn = node !== undefined && current !== node && - this.flowsOnTo(node, current, hops); + this.flowsOnTo(node, current); if (!flowsOn) this.emitStartsInto(current, lines); else if (current !== this.elidedTail) { this.pushGoto(current, lines); @@ -878,7 +1072,8 @@ class Emitter { } const next = this.emitNode(current, stop, lines, depth); if (next === STOP) return; - if (this.linkedCatch.get(current) === next) hops.add(current); + if (this.linkedCatch.get(current) === next) + this.adjoinedHops.add(current); current = next; } } @@ -892,7 +1087,12 @@ class Emitter { private leavesInnermostBranch(current: string): boolean { const branch = this.branch!; if ( - this.liesOutsideBranch(current, branch.route, branch.join) && + this.liesOutsideBranch( + current, + branch.route, + branch.join, + branch.ownEntry === undefined, + ) && (branch.ownEntry === undefined || this.reachesEnclosingJoin(current, branch)) ) { @@ -905,7 +1105,7 @@ class Emitter { } return ( ancestor !== undefined && - this.liesOutsideBranch(current, ancestor.route, ancestor.join) + this.liesOutsideBranch(current, ancestor.route, ancestor.join, true) ); } @@ -913,27 +1113,37 @@ class Emitter { * 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. + * `join` stays in the branch, since no outside placement is valid, unless + * the branch is an `await`/`parallel` one entered from a node the fork never + * reaches, with no way back to the fork: printed behind that node, it takes + * the branch's `goto`, which leads out of the branch rather than in. */ private liesOutsideBranch( node: string, route: SequenceFlow, join: string | undefined, + forkBranch: boolean, ): boolean { + const fork = route.sourceRef; const first = route.targetRef; - const entered = this.cfg + const outside = this.cfg .incoming(node) - .some( + .filter( (p) => - this.cfg.loopImmediateDominator(p) !== undefined && - !this.cfg.loopDominates(first, p) && - !(p === route.sourceRef && node === first), + !this.cfg.loopDominates(first, p) && !(p === fork && node === first), ); - return ( - entered && - (join === undefined || - !this.reachable(node, route.sourceRef, undefined).has(join)) - ); + if (outside.length === 0) return false; + if ( + join === undefined || + !this.reachable(node, fork, undefined).has(join) + ) { + return true; + } + if (!forkBranch || this.reachable(node, undefined, fork).has(fork)) { + return false; + } + const fromFork = this.reachable(fork, undefined, undefined); + return outside.some((p) => !fromFork.has(p)); } /** @@ -950,7 +1160,12 @@ class Emitter { } return ( route !== undefined && - this.liesOutsideBranch(entry, route, this.ownMerges.get(route.sourceRef)) + this.liesOutsideBranch( + entry, + route, + this.ownMerges.get(route.sourceRef), + true, + ) ); } @@ -1092,16 +1307,15 @@ class Emitter { } this.emittedNodes.add(id); const head = el.element === 'transaction' ? 'attempt' : 'subprocess'; - lines.push( - ...bodyHeader( - `${head} ${nameOf(this.names, id)}${repeatClause(el)}`, + pushBlock( + lines, + bodyHeader( + `${head} ${printName(id)}${repeatClause(el)}`, [...namedSettings(el), ...engineSettings(el)], structuredMembers(el), ), + new Emitter(el, this.warnings, this.names).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); } @@ -1219,7 +1433,7 @@ class Emitter { splitId, outs, ); - this.warnInventedFallback(splitId); + const fails = this.failsWithoutMatch(splitId); const found = this.ownJoin(splitId, outs, stop) ?? @@ -1232,8 +1446,9 @@ class Emitter { ? 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; + // lose its condition without changing which route a holding condition + // takes, and under `else fail` none can: it prints as an empty branch. + const fallThrough = !fails && noMatch === outs.at(-1) ? noMatch : undefined; const join = found ?? noMatch?.targetRef; for (const f of outs) this.consumedFlows.add(f.id); @@ -1243,9 +1458,10 @@ class Emitter { fallback, join, splitId, - [...this.takeHeadSettings(splitId), ...this.takeJoinSettings(join)], + this.blockSettings(splitId, join), lines, depth, + fails, ); return join !== undefined ? this.continueAt(join, stop, lines) : STOP; @@ -1287,6 +1503,7 @@ class Emitter { settings: string[], lines: string[], depth: number, + fails = false, ): void { // Operaton takes the first route in document order that has no condition // or one that holds, so the heads keep that order and only a route listed @@ -1319,7 +1536,7 @@ class Emitter { ? [] : this.emitIfBranch(elseFlow, join, splitId, depth); if (elseBody.length > 0) lines.push('} else {', ...elseBody); - lines.push('}'); + lines.push(fails ? `} else ${ELSE_FAIL_WORD}` : '}'); } /** @@ -1450,15 +1667,11 @@ class Emitter { const shared = [...live[0]!.reach].filter( (n) => live.every((r) => r.reach.has(n)) && - (n === stop || !this.cfg.dominates(n, splitId)) && + (n === stop || + (!this.cfg.dominates(n, splitId) && !this.emittedNodes.has(n))) && !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)), - ); + const merge = shared.find((n) => this.isMerge(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 @@ -1498,39 +1711,6 @@ class Emitter { ); } - /** - * 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. @@ -1600,12 +1780,11 @@ class Emitter { } /** - * 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. + * With no join, the jump passes would print whichever chain comes next after + * the `if`, and the next print would 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, @@ -1621,8 +1800,10 @@ class Emitter { ) { return undefined; } + // A printed entry resumes as a `goto`, which no other route can share. const resumable = (f: SequenceFlow): boolean => - this.cfg.incoming(f.targetRef).length === 1; + this.cfg.incoming(f.targetRef).length === 1 || + this.emittedNodes.has(f.targetRef); const last = outs.at(-1)!; return resumable(last) ? last : outs.find(resumable); } @@ -1643,7 +1824,7 @@ class Emitter { if (join === undefined) { this.pushGoto(entry, body); } else if (entry === join) { - // Empty branch: no body. + // An empty branch. } else if ( !this.emittedNodes.has(entry) && this.branchStaysInRegion(entry, join, splitId) @@ -1715,29 +1896,39 @@ class Emitter { 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. + // Predecessors that leave the branch its entry: 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 doWhileEdges = this.doWhileBackEdges(entry); + const latches = new Set( + [ + ...doWhileEdges, + ...(this.cfg.ownLoopTest(entry) === entry + ? this.cfg.backEdges().filter((f) => f.targetRef === entry) + : []), + ].map((f) => f.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)) || + latches.has(p) || this.jumpsOutOfBranch(p, entry) || this.reachedOnlyThroughBoundary(p), ); - if (el === undefined || !owned || this.cfg.dominates(join, entry)) { + // A printed join is behind the split, so dominating `entry` does not put + // `entry` after it. + if ( + el === undefined || + !owned || + (this.cfg.dominates(join, entry) && !this.emittedNodes.has(join)) + ) { return false; } if (isGateway(el)) return true; @@ -1755,7 +1946,7 @@ class Emitter { } const after = this.reachable(join, splitId, undefined); if (this.elidedTail === undefined) { - if (this.doWhileBackEdges(entry).length > 0) return true; + if (doWhileEdges.length > 0) return true; if (this.cfg.incoming(entry).some((p) => p !== splitId && after.has(p))) { return false; } @@ -1786,7 +1977,8 @@ class Emitter { .backEdges() .some( (f) => - this.isDoWhileTest(f) && this.cfg.loopDominates(f.targetRef, splitId), + this.doWhileTests.has(f) && + this.cfg.loopDominates(f.targetRef, splitId), ); } @@ -1851,9 +2043,7 @@ class Emitter { this.branch = { route: branch, join, outer: outerBranch }; } const body: string[] = []; - if (entry !== join) { - this.emitFrom(entry, join, body, depth + 1); - } + 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; @@ -1878,7 +2068,6 @@ class Emitter { // 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 fallbackId = this.splitFallbackFlowId(loop.id); const cond = this.routesOut(loop.id).find((f) => f.id !== fallbackId); if ( @@ -1888,17 +2077,39 @@ class Emitter { return undefined; } - this.emittedNodes.add(loop.id); - this.consumedFlows.add(cond.id); - this.consumedFlows.add(backEdge.id); - const rest = this.takeRest(outs); - const settings = this.takeHeadSettings(loop.id); - - lines.push(`while (${renderCondition(cond)})${headSettings(settings)} {`); - this.emitBranch(cond.targetRef, loop.id, lines, depth); - lines.push('}'); + return this.emitLoop( + loop.id, + [cond, backEdge], + cond.targetRef, + (settings) => [`while (${renderCondition(cond)})${settings} {`, '}'], + stop, + lines, + depth, + ); + } - return this.emitRoutes(loop.id, rest, stop, lines, depth); + /** + * The routes left after `spent` are consumed before the body walk, so a jump + * onto the head sees the same routing. + */ + private emitLoop( + loopId: string, + spent: readonly SequenceFlow[], + entry: string, + frame: (settings: string) => [open: string, close: string], + stop: string | undefined, + lines: string[], + depth: number, + ): string | typeof STOP { + this.emittedNodes.add(loopId); + for (const f of spent) this.consumedFlows.add(f.id); + const rest = this.unconsumedOut(loopId); + for (const f of rest) this.consumedFlows.add(f.id); + const [open, close] = frame(headSettings(this.takeHeadSettings(loopId))); + lines.push(open); + this.emitBranch(entry, loopId, lines, depth); + lines.push(close); + return this.emitRoutes(loopId, rest, stop, lines, depth); } /** @@ -1939,27 +2150,23 @@ class Emitter { ); if (outermost.length !== 1) return undefined; const backEdge = outermost[0]!; - - const loopId = backEdge.sourceRef; - const outs = this.unconsumedOut(loopId); - - this.emittedNodes.add(loopId); - 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(backEdge)})${headSettings(settings)}`, + return this.emitLoop( + backEdge.sourceRef, + [backEdge], + node, + (settings) => [ + 'do {', + `} while (${renderCondition(backEdge)})${settings}`, + ], + stop, + lines, + depth, ); - - 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), + (f) => f.targetRef === node && this.doWhileTests.has(f), ); // Once `ownLoopTest` names another gateway, only its back edge qualifies. const owner = this.cfg.ownLoopTest(node); @@ -1988,8 +2195,7 @@ class Emitter { 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()) + this.reachable(f.targetRef, undefined, head.id).has(head.id) ); } @@ -2016,16 +2222,6 @@ class Emitter { }); } - /** - * 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); - for (const f of rest) this.consumedFlows.add(f.id); - return rest; - } - /** * 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. @@ -2051,19 +2247,27 @@ class Emitter { return this.takeFallThrough(outs[0]!, stop, lines); } - const join = - this.ownJoin(splitId, outs, stop, kind) ?? - this.recoveredForkJoin(splitId, outs, kind); + const join = this.forkJoin(splitId, outs, stop, kind); + + const meet = + join === undefined ? this.unsyncedMeet(splitId, stop) : undefined; for (const f of outs) this.consumedFlows.add(f.id); - if (join === undefined && !this.routesEndApart(splitId, outs, stop)) { - this.warnings.push(degradedSplitWarning(splitId)); + if (join === undefined && !this.routesEndApart(splitId, outs, stop, meet)) { + this.warnings.push( + degradedSplitWarning( + splitId, + isGateway(this.byId.get(splitId)!) ? 'gateway' : 'element', + ), + ); this.emitJumps(splitId, kind, outs, lines, depth); return STOP; } - this.warnInventedFallback(splitId); + if (join === undefined && meet === undefined) { + this.warnAddedJoin(splitId, outs); + } this.warnUnweighedBranchCondition(fork, outs); // A fallback straight into the merge is implicit; omit it while two @@ -2071,12 +2275,9 @@ class Emitter { const kept = outs.filter((f) => !this.isImplicitFallback(fork, f, join)); const branches = kept.length >= 2 ? kept : outs; this.warnDeadFallback(fork, branches); + this.warnChangedJoin(fork, join, branches); - const settings = [ - ...this.takeHeadSettings(splitId), - ...this.takeJoinSettings(join), - ]; - lines.push(`parallel${headSettings(settings)} {`); + lines.push(`parallel${headSettings(this.blockSettings(splitId, join))} {`); branches.forEach((f) => { const branchLines: string[] = []; this.emitBranch(f.targetRef, join, branchLines, depth, f); @@ -2084,20 +2285,73 @@ class Emitter { for (const l of branchLines) lines.push(INDENT + l); lines.push(INDENT + '}'); }); + if (this.failsWithoutMatch(splitId)) { + lines.push(INDENT + `else ${ELSE_FAIL_WORD}`); + } lines.push('}'); - return join === undefined ? STOP : this.continueAt(join, stop, lines); + const after = join ?? meet; + return after === undefined ? STOP : this.continueAt(after, stop, lines); + } + + /** + * Where the routes out of a fork meet at a step with no join, as in a fork + * whose routes all run into one end event. Each branch jumps to it and it + * prints after the block, whose join then has no route in, so the compiler + * leaves it out and the step is entered once per route, as in the model. + */ + private unsyncedMeet( + splitId: string, + stop: string | undefined, + ): string | undefined { + const meet = this.cfg.immediatePostDominator(splitId); + const el = meet === undefined ? undefined : this.byId.get(meet); + if ( + el === undefined || + isGateway(el) || + el.id === stop || + this.emittedNodes.has(el.id) || + this.cfg.incoming(el.id).length < 2 || + isElidedOnPrint(el, this.container) || + !this.cfg.dominates(splitId, el.id) + ) { + return undefined; + } + return el.id; + } + + /** + * The compiler joins every branch of a block that does not end, so two + * routes stopping at a step with no way on meet at a join the model lacks. + */ + private warnAddedJoin(splitId: string, outs: readonly SequenceFlow[]): void { + const openEnds = outs.map((f) => { + const seen = new Set([f.targetRef]); + const stops: string[] = []; + for (const n of seen) { + if (this.emittedNodes.has(n)) continue; + const next = this.successors(n); + if (next.length === 0 && this.byId.get(n)?.kind !== 'endEvent') { + stops.push(n); + } + for (const m of next) seen.add(m); + } + return stops; + }); + if (openEnds.filter((stops) => stops.length > 0).length < 2) return; + this.warnings.push(addedJoinWarning(splitId, openEnds.flat())); } /** * 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. + * through its head alone, ending or jumping to a nameable printed node or + * to `meet`, and not reaching the block exit. Only the routes enter `meet`. */ private routesEndApart( splitId: string, outs: readonly SequenceFlow[], stop: string | undefined, + meet?: string, ): boolean { const exit = stop ?? this.elidedTail; const route = new Map(); @@ -2107,6 +2361,10 @@ class Emitter { for (let i = 0; i < queue.length; i++) { const n = queue[i]!; if (n === exit) return false; + if (n === meet) { + ends = true; + continue; + } if (this.emittedNodes.has(n)) { if (this.forwardToRealTarget(n, new Set()) === undefined) { return false; @@ -2124,13 +2382,35 @@ class Emitter { } if (!ends) return false; } - return [...route].every(([n, f]) => - this.cfg - .incoming(n) - .every( - (p) => route.get(p) === f || (p === splitId && n === f.targetRef), - ), + return ( + [...route].every(([n, f]) => + this.cfg + .incoming(n) + .every( + (p) => route.get(p) === f || (p === splitId && n === f.targetRef), + ), + ) && + (meet === undefined || + this.cfg.incoming(meet).every((p) => route.has(p) || p === splitId)) + ); + } + + /** + * The compiler makes a block inclusive only for a weighed branch, so an + * inclusive fork that weighs none reads back as parallel. The fork runs the + * same either way, its join does not. + */ + private warnChangedJoin( + fork: Fork, + join: string | undefined, + branches: readonly SequenceFlow[], + ): void { + if (join === undefined || this.byId.get(join)?.kind !== 'inclusiveGateway') + return; + const weighed = branches.some( + (f) => f.id !== fork.fallbackId && f.conditionExpression !== undefined, ); + if (!weighed) this.warnings.push(changedJoinWarning(join)); } /** @@ -2157,13 +2437,18 @@ class Emitter { ); } - /** Reads model routes: the engine weighs every route, printed or not. */ - private warnInventedFallback(splitId: string): void { - if (this.byId.get(splitId)?.kind === 'parallelGateway') return; - if (this.splitFallbackFlowId(splitId) !== undefined) return; - const outs = this.routesOut(splitId); - if (outs.some((f) => f.conditionExpression === undefined)) return; - this.warnings.push(inventedFallbackWarning(splitId)); + /** + * Whether the engine stops the run when no condition holds, which prints as + * `else fail`. Reads model routes: the engine weighs every route, printed or + * not. A step split throws as its gateway form does + * (`BpmnActivityBehavior.handleNoTransitions`). + */ + private failsWithoutMatch(splitId: string): boolean { + return ( + this.byId.get(splitId)?.kind !== 'parallelGateway' && + this.splitFallbackFlowId(splitId) === undefined && + this.routesOut(splitId).every((f) => f.conditionExpression !== undefined) + ); } /** @@ -2201,13 +2486,12 @@ class Emitter { * fork or step ignores it. */ private warnFallbackCondition(splitId: string): void { - if (this.splitFallbackFlow(splitId)?.conditionExpression === undefined) { - return; - } + const condition = this.splitFallbackFlow(splitId)?.conditionExpression; + if (condition === undefined) return; this.warnings.push( this.byId.get(splitId)?.kind === 'exclusiveGateway' - ? choiceFallbackConditionWarning(splitId) - : forkFallbackConditionWarning(splitId), + ? choiceFallbackConditionWarning(splitId, condition) + : forkFallbackConditionWarning(splitId, condition), ); } @@ -2215,21 +2499,25 @@ class Emitter { 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)); + this.warnings.push(unweighedBranchWarning(fork.id, outs)); } /** - * 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. + * The fork's own merge, else, when a branch ends and leaves the fork no + * post-dominator, where the survivors 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( + private forkJoin( forkId: string, outs: SequenceFlow[], + stop: string | undefined, joinKind: Gateway['kind'], ): string | undefined { + const own = this.ownJoin(forkId, outs, stop, joinKind); + if (own !== undefined) return own; const survivorChains: string[][] = []; for (const f of outs) { const chain: string[] = []; @@ -2295,19 +2583,13 @@ class Emitter { } if (outs.some((f) => f.conditionExpression !== undefined)) { - this.warnings.push(raceConditionWarning(race.id)); + this.warnings.push(raceConditionWarning(race.id, outs)); } // An XOR merge: exactly one branch of a race ever runs. - const join = - this.ownJoin(race.id, outs, stop, 'exclusiveGateway') ?? - this.recoveredForkJoin(race.id, outs, 'exclusiveGateway'); - - const settings = [ - ...this.takeHeadSettings(race.id), - ...this.takeJoinSettings(join), - ]; - lines.push(`await${headSettings(settings)} {`); + const join = this.forkJoin(race.id, outs, stop, 'exclusiveGateway'); + + lines.push(`await${headSettings(this.blockSettings(race.id, join))} {`); waits.forEach((wait, i) => { const { el, body } = wait!; this.emittedNodes.add(el.id); @@ -2347,7 +2629,10 @@ class Emitter { private consume(flow: SequenceFlow): void { this.consumedFlows.add(flow.id); if (flow.conditionExpression === undefined) return; - const warning = this.droppedConditionWarning(flow); + const warning = this.droppedConditionWarning( + flow, + flow.conditionExpression, + ); if (warning !== undefined) this.warnings.push(warning); } @@ -2366,21 +2651,31 @@ class Emitter { */ private droppedConditionWarning( flow: SequenceFlow, + condition: string, ): PrintWarning | undefined { const sourceId = flow.sourceRef; - const kind = this.byId.get(sourceId)?.kind; + const source = this.byId.get(sourceId); if (flow.id === this.splitFallbackFlowId(sourceId)) return undefined; - switch (kind) { + switch (source?.kind) { case 'parallelGateway': - return unweighedBranchWarning(sourceId); + return unweighedBranchWarning(sourceId, [flow]); case 'eventBasedGateway': - return raceConditionWarning(sourceId); + return raceConditionWarning(sourceId, [flow]); default: { + const place = + source !== undefined && isGateway(source) + ? `gateway '${sourceId}'` + : `the route out of '${sourceId}'`; const fallback = this.splitFallbackFlow(sourceId); return fallback !== undefined && fallback.conditionExpression === undefined - ? divertedRunWarning(sourceId) - : droppedFlowConditionWarning(sourceId); + ? divertedRunWarning(sourceId, place, condition) + : droppedFlowConditionWarning( + sourceId, + place, + condition, + this.routesOut(sourceId).length === 1, + ); } } } @@ -2408,9 +2703,16 @@ class Emitter { * choice drops the edge. */ private pushGoto(target: string, lines: string[]): void { - const real = this.forwardToRealTarget(target, new Set()); + const crossed = new Set(); + const real = this.forwardToRealTarget(target, crossed); if (real !== undefined) { - lines.push(`goto ${nameOf(this.names, real)}`); + for (const id of crossed) { + if (this.isSynchronizing(id) && !this.skippedJoins.has(id)) { + this.skippedJoins.add(id); + this.warnings.push(skippedJoinWarning(id, real)); + } + } + lines.push(`goto ${printName(real)}`); this.jumps.push({ target: real, branchPath: this.branchPath }); return; } @@ -2455,7 +2757,7 @@ class Emitter { ): string { return isSynthesizedTerminalId(el.id, el.kind, this.container) ? '' - : ` ${nameOf(this.names, el.id)}`; + : ` ${printName(el.id)}`; } private renderStatement(el: FlowElement): Lines | undefined { @@ -2471,7 +2773,7 @@ class Emitter { if (definition === undefined || isEndCarried(definition)) { const head = definition === undefined ? '' : ` ${definition.kind}`; return bracketed( - `end ${nameOf(this.names, el.id)}${head}`, + `end ${printName(el.id)}${head}`, [...namedSettings(el), ...jobSettingItems(el)], members, ); @@ -2510,7 +2812,7 @@ class Emitter { return renderServiceTask(el, this.names); case 'task': return bracketed( - `step ${nameOf(this.names, el.id)}${repeatClause(el)}`, + `step ${printName(el.id)}${repeatClause(el)}`, [...namedSettings(el), ...engineSettings(el)], structuredMembers(el), ); @@ -2580,25 +2882,69 @@ function droppedEdgeWarning(target: string): PrintWarning { elementId: target, 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.', + `a route into '${target}' was left out of the script\n` + + 'note: the route has no form in the script, so this part of the script is unstructured\n' + + `note: the marker comment where it belonged names '${target}' and is where hand-repair starts`, }; } -function degradedSplitWarning(splitId: string): PrintWarning { +function degradedSplitWarning( + splitId: string, + noun: 'gateway' | 'element' = 'gateway', +): PrintWarning { return { elementId: splitId, category: 'degradedSplit', message: - 'The branches leaving this split have no form in the script, so a ' + + `${noun} '${splitId}' was printed as jumps and its split was lost\n` + + 'note: the branches leaving it 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. 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.', + 'the script cannot name\n' + + 'note: a condition weighing a branch is written on its jump\n' + + 'note: whether the split opened one branch, every branch whose ' + + 'condition held, or the first to resolve is written nowhere\n' + + 'note: a jump ends the path it sits on, so at most one branch is left running\n' + + 'note: the marker line above the jumps is where hand-repair starts', + }; +} + +function addedJoinWarning(splitId: string, stops: string[]): PrintWarning { + return { + elementId: splitId, + category: 'addedJoin', + message: + `the routes out of '${splitId}' stop at ${formatPlainWordList( + stops.map((id) => `'${id}'`), + 'and', + )} without an end event, and the rebuilt process joins them\n` + + "note: the script has no form for a route that stops without an end event, so the branches of a 'parallel' block meet at a join after it\n" + + 'note: the engine ends each of these routes where it stops\n' + + 'note: the rebuilt process holds each one at the new join until the others arrive\n' + + 'note: past the join it runs on as one route, which ends at an added end event when nothing follows the block', + }; +} + +function changedJoinWarning(joinId: string): PrintWarning { + return { + elementId: joinId, + category: 'changedJoin', + message: + `inclusive gateway '${joinId}' was printed as the join of a 'parallel' block\n` + + 'note: no branch of the block carries a condition, so the script reads the block back as parallel gateways\n' + + `note: a parallel join waits for a route from every branch, where '${joinId}' goes on once no other route can still reach it\n` + + 'note: so a route led away from its branch before the join, by an interrupting boundary event for one, leaves the rebuilt process waiting at the join forever', + }; +} + +function skippedJoinWarning(joinId: string, target: string): PrintWarning { + return { + elementId: joinId, + category: 'skippedJoin', + message: + `a jump was printed past gateway '${joinId}' and its join was lost\n` + + `note: the route into it has no form in the script, so it leaves as a jump to '${target}'\n` + + `note: a route taking the jump goes on to '${target}' at once instead of waiting at '${joinId}' for the others\n` + + `note: so '${target}' and what follows run once for each such route instead of once after the join`, }; } @@ -2606,29 +2952,33 @@ 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.', + parallelGateway: [ + "note: the engine ends the run here too, so outside a fork's branch the process runs the same without it", + 'note: inside a branch, 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.', + inclusiveGateway: [ + 'note: 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.', + eventBasedGateway: [ + 'note: 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.', + exclusiveGateway: [ + 'note: the engine refuses to deploy the model as drawn, while the ' + + 'script deploys and ends the run here', + ], }[kind]; return { elementId: id, category: 'degradedSplit', - message: - `This ${kind === 'eventBasedGateway' ? 'wait' : 'split'} has no route ` + - `out, so it was left out of the script. ${model}`, + message: [ + `gateway '${id}' has no route out and was left out of the script`, + ...model, + ].join('\n'), }; } @@ -2637,109 +2987,114 @@ function crossBranchJumpWarning(target: string): PrintWarning { 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.', + `the jump into '${target}' crosses the border of a branch of an 'await' or 'parallel' block\n` + + 'note: the printed script draws an error there when it is read back\n' + + 'note: the steps of a branch run only when the whole block is reached\n' + + 'help: redraw the route in the model, or move the step out of the branch', }; } -function inventedFallbackWarning(splitId: string): PrintWarning { - return { - elementId: splitId, - category: 'defaultFlow', - message: - '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.', - }; +/** Quotes the condition when exactly one of `flows` carries one. */ +function quotedCondition(flows: readonly SequenceFlow[]): string { + const conditions = flows.flatMap((f) => f.conditionExpression ?? []); + return conditions.length === 1 ? ` '${oneLine(conditions[0]!)}'` : ''; } -function raceConditionWarning(raceId: string): PrintWarning { +function raceConditionWarning( + raceId: string, + flows: readonly SequenceFlow[], +): PrintWarning { return { elementId: raceId, category: 'droppedCondition', message: - 'The model weighs a branch of this wait with a condition, which the ' + - 'script leaves out. A wait opens every branch at once and takes the ' + - 'first to resolve, so the condition is weighed nowhere and the run is ' + - 'the same without it. Check that the condition was not meant to weigh ' + - 'a branch of a split instead.', + `condition${quotedCondition(flows)} on a branch of gateway '${raceId}' was dropped\n` + + 'note: a wait opens every branch at once and takes the first to ' + + 'resolve, so the condition is weighed nowhere\n' + + 'note: the run is the same without it', }; } -function droppedFlowConditionWarning(sourceId: string): PrintWarning { +/** `place` is where the route starts, `only` whether it is the sole route out. */ +function droppedFlowConditionWarning( + sourceId: string, + place: string, + condition: string, + only: boolean, +): PrintWarning { return { elementId: sourceId, category: 'droppedCondition', message: - 'The model weighs the route on from here with a condition, which the ' + - 'script leaves out: one step leads straight to the next, and there is ' + - 'no place between them to write a condition. The engine reads that ' + - 'condition and takes the route only when it holds, so where the model ' + - 'has nothing left to take and stops the run with an error, the script ' + - 'carries straight on. Check that the condition was not meant to weigh ' + - 'a branch of a split.', + `condition '${oneLine(condition)}' on ${place} was dropped\n` + + (only + ? `note: it is the only route out of '${sourceId}'` + : `note: it is printed as the plain route on from '${sourceId}'`) + + ', and BPMNscript writes conditions only on the branches of a split\n' + + 'note: when the condition is false, Operaton stops the original process with an error\n' + + 'note: the rebuilt process always continues', }; } -function divertedRunWarning(splitId: string): PrintWarning { +function divertedRunWarning( + splitId: string, + place: string, + condition: string, +): PrintWarning { return { elementId: splitId, category: 'droppedCondition', message: - 'The model weighs the route on from this split with a condition, which ' + - 'the script leaves out: one step leads straight to the next, and there ' + - 'is no place between them to write a condition. The engine reads that ' + - 'condition and takes the route only when it holds. The split names the ' + - 'route to take when nothing holds, so the model leaves by another ' + - 'route rather than stopping, while the script carries straight on down ' + - 'this one. Nothing fails here: what differs is the route the run ' + - 'takes, not whether it runs. Check that taking this route whatever the ' + - 'condition says is what was meant.', + `condition '${oneLine(condition)}' on ${place} was dropped\n` + + `note: the route is printed as the plain way on from '${splitId}', ` + + 'and BPMNscript writes conditions only on the branches of a split\n' + + `note: when the condition is false, Operaton takes the fallback of '${splitId}' instead\n` + + 'note: the rebuilt process always takes this route\n' + + 'note: nothing fails here, only the route the run takes differs', }; } -function unweighedBranchWarning(forkId: string): PrintWarning { +function unweighedBranchWarning( + forkId: string, + flows: readonly SequenceFlow[], +): PrintWarning { return { elementId: forkId, category: 'droppedCondition', message: - 'The model weighs a route leaving this split with a condition, which ' + - 'the script leaves out. A split of this kind takes every route ' + - 'whatever the conditions say, so the condition is weighed nowhere and ' + - 'the run is the same without it. Writing it would read back as the ' + - 'split that does weigh its branches, so it is left out. Check that ' + - 'the condition was not meant to weigh a branch of that split instead.', + `condition${quotedCondition(flows)} on a branch of parallel gateway '${forkId}' was dropped\n` + + 'note: a parallel gateway takes every route whatever the conditions ' + + 'say, so the condition is weighed nowhere and the run is the same without it\n' + + 'note: written out, it would read back as the split that does weigh its branches', }; } -function forkFallbackConditionWarning(forkId: string): PrintWarning { +function forkFallbackConditionWarning( + forkId: string, + condition: string, +): PrintWarning { return { elementId: forkId, category: 'defaultFlow', message: - 'The model weighs the fallback of this split with a condition, which ' + - 'the script leaves out. A fallback runs when no other branch does, ' + - 'whatever its condition says, so the run is the same without it. Check ' + - 'that the condition was not meant to weigh a branch of its own.', + `condition '${oneLine(condition)}' on the fallback of gateway '${forkId}' was dropped\n` + + 'note: a fallback runs when no other branch does, whatever its ' + + 'condition says, so the run is the same without it', }; } -function choiceFallbackConditionWarning(splitId: string): PrintWarning { +function choiceFallbackConditionWarning( + splitId: string, + condition: string, +): PrintWarning { return { elementId: splitId, category: 'defaultFlow', message: - 'The model weighs the fallback of this split with a condition, which ' + - 'the script leaves out. A split of this kind takes one route, and the ' + - 'engine refuses to deploy one whose fallback is weighed. The model as ' + - 'drawn does not run, while the script without the condition deploys ' + - 'and runs. Check that the condition was not meant to weigh a branch of ' + - 'its own.', + `condition '${oneLine(condition)}' on the fallback of exclusive gateway '${splitId}' was dropped\n` + + 'note: an exclusive gateway takes one route, and the engine refuses ' + + 'to deploy one whose fallback is weighed\n' + + 'note: the original process does not deploy, while the rebuilt one deploys and runs', }; } @@ -2748,11 +3103,12 @@ function deadFallbackWarning(forkId: string): PrintWarning { elementId: forkId, category: 'defaultFlow', message: - 'This split names a fallback, and a branch beside it runs whatever the ' + - 'conditions do, so nothing is ever left over for the fallback to take. ' + - "The script writes it out as an 'else' all the same, which draws an " + - 'error when the source is read back. Put a condition on the branches ' + - 'beside it, or drop the fallback.', + `fallback of gateway '${forkId}' can never run\n` + + 'note: a branch beside it runs whatever the conditions do, so ' + + 'nothing is ever left over for the fallback to take\n' + + "note: the script writes it out as an 'else' all the same, which " + + 'draws an error when the source is read back\n' + + 'help: put a condition on the branches beside it, or drop the fallback', }; } @@ -2761,10 +3117,10 @@ function droppedSettingWarning(gatewayId: string): PrintWarning { elementId: gatewayId, category: 'droppedSetting', message: - 'The engine settings on this split or merge were not written to the ' + - 'script: the script derives every split and every merge from its ' + - 'block structure, and this one has no statement here to carry them, ' + - 'so the process runs without them.', + `engine settings on gateway '${gatewayId}' were not written to the script\n` + + 'note: the script derives every split and every merge from its block ' + + 'structure, and this gateway has no statement to carry them\n' + + 'note: the process runs without them', }; } @@ -2786,7 +3142,11 @@ function buildProcessHeader(process: BpmnProcess, names: PrintNames): string { const settings: string[] = namedSettings(process); for (const key of PROCESS_ENGINE_HEADER_KEYS) { const value = process[key]; - if (value !== undefined) { + // The compiler writes this value for a script that sets none, so printing + // it would add a line nobody wrote. + const isDefault = + key === 'historyTimeToLive' && value === HISTORY_TIME_TO_LIVE; + if (value !== undefined && !isDefault) { settings.push(setting(key, PROCESS_HEADER_RENDER[key](value))); } } @@ -2795,7 +3155,7 @@ function buildProcessHeader(process: BpmnProcess, names: PrintNames): string { setting(STARTABLE_KEY, String(process.isStartableInTasklist)), ); } - return `process ${nameOf(names, process.id)}${parens(settings)} {`; + return `process ${printName(process.id)}${parens(settings)} {`; } function isHandler( @@ -3125,19 +3485,35 @@ function jobSettingItems( ); } -/** Own settings, then the loop's under the `run` spellings. */ +/** Own settings, then the loop's under the `run` spellings and its documentation. */ function engineSettings(el: EngineAttributes & Repeatable): string[] { return [ ...jobSettingItems(el), - ...(repeats(el.loop) ? jobSettingItems(el.loop, runSettingKey) : []), + ...(repeats(el.loop) + ? [ + ...jobSettingItems(el.loop, runSettingKey), + ...documentationSetting( + VALUE_DOCUMENTATION_KEYS.count, + el.loop.cardinalityDocumentation, + ), + ...documentationSetting( + VALUE_DOCUMENTATION_KEYS.each, + el.loop.elementVariableDocumentation, + ), + ] + : []), ]; } +function documentationSetting(key: string, text: string | undefined): string[] { + return text === undefined ? [] : [setting(key, quoteLiteral(text))]; +} + /** 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_DIRECTION} ${field.name} = ${quote(field.value)}`, + `${FIELD_DIRECTION} ${printName(field.name)} = ${quote(field.value)}`, ]); } @@ -3146,12 +3522,12 @@ function ioParameters(el: IoMapped): Lines[] { const members: Lines[] = []; for (const param of el.inputParameters ?? []) { members.push([ - `${INPUT_DIRECTION} ${param.name} = ${renderIoValue(param.value)}`, + `${INPUT_DIRECTION} ${printName(param.name)} = ${renderIoValue(param.value)}`, ]); } for (const param of el.outputParameters ?? []) { members.push([ - `${OUTPUT_DIRECTION} ${param.name} = ${renderIoValue(param.value)}`, + `${OUTPUT_DIRECTION} ${printName(param.name)} = ${renderIoValue(param.value)}`, ]); } return members; @@ -3166,7 +3542,7 @@ function renderIoValue(value: IoValue): string { case 'text': return quote(value.text); case 'list': - return `[${value.items.map((item) => renderIoValue(item)).join(', ')}]`; + return `[${value.items.map(renderIoValue).join(', ')}]`; case 'map': { const entries = value.entries.map( (entry) => `${quoteLiteral(entry.key)}: ${renderIoValue(entry.value)}`, @@ -3210,10 +3586,16 @@ function renderListener( ? ` ${TIMER_PARTICLE_BY_KIND[timer.timerKind]} ${quote(timer.expression)}` : ''; const head = `on ${event}${clause}`; + const timerDocumentation = documentationSetting( + VALUE_DOCUMENTATION_KEYS.timer, + timer?.expressionDocumentation, + ); return binding.kind === 'script' - ? [`${head} ${renderFence(binding.format, binding.code)}`] + ? [ + `${head}${timerDocumentation.length > 0 ? parens(timerDocumentation) : ''} ${renderFence(binding.format, binding.code)}`, + ] : withMembers( - head + parens([renderCodeBinding(binding)]), + head + parens([renderCodeBinding(binding), ...timerDocumentation]), fieldMembers(binding), ); } @@ -3247,7 +3629,7 @@ function renderFence(format: string, code: string): string { * 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( +export function isSynthesizedTerminalId( id: string, kind: FlowElement['kind'], container: PrintContainer, @@ -3340,7 +3722,7 @@ function buildBoundaryHeader( ): Lines { const trigger = renderTrigger(boundary.eventDefinition, names); return bodyHeader( - `on ${nameOf(names, boundary.attachedToRef)}: ${trigger.head}`, + `on ${printName(boundary.attachedToRef)}: ${trigger.head}`, [ ...trigger.items, ...jobSettingItems(boundary), @@ -3385,7 +3767,13 @@ function renderTrigger( case 'conditional': return { head: 'condition', - items: [renderRawCondition(def.condition)], + items: [ + renderRawCondition(def.condition), + ...documentationSetting( + VALUE_DOCUMENTATION_KEYS.condition, + def.conditionDocumentation, + ), + ], }; case 'link': return { head: 'link', items: payloadItem(def.linkName) }; @@ -3412,7 +3800,7 @@ function codeItem( names: ReadonlyMap, code: string | undefined, ): string[] { - return code === undefined ? [] : [names.get(code) ?? code]; + return code === undefined ? [] : [names.get(code) ?? printName(code)]; } function renderTimerTrigger(def: Extract): { @@ -3423,7 +3811,13 @@ function renderTimerTrigger(def: Extract): { const time = quote(def.expression); return { head: 'timer', - items: [def.timerKind === 'duration' ? time : setting(particle, time)], + items: [ + def.timerKind === 'duration' ? time : setting(particle, time), + ...documentationSetting( + VALUE_DOCUMENTATION_KEYS.timer, + def.expressionDocumentation, + ), + ], }; } @@ -3455,7 +3849,7 @@ function renderStartEvent( : renderTrigger(el.eventDefinition, names); const head = trigger.head === '' ? '' : ` ${trigger.head}`; return bracketed( - `start ${nameOf(names, el.id)}${head}`, + `start ${printName(el.id)}${head}`, [ ...trigger.items, ...namedSettings(el), @@ -3492,21 +3886,17 @@ function renderUserTask( settings.push(...engineSettings(el)); const form = el.formFields === undefined ? [] : [renderFormBlock(el.formFields)]; - return bracketed( - `user ${nameOf(names, el.id)}${repeatClause(el)}`, - settings, - [...form, ...structuredMembers(el)], - ); + return bracketed(`user ${printName(el.id)}${repeatClause(el)}`, settings, [ + ...form, + ...structuredMembers(el), + ]); } /** Braces even with no fields: `form` alone does not parse. */ function renderFormBlock(formFields: FormField[]): Lines { return [ 'form {', - ...formFields - .map((field) => renderFormField(field)) - .flat() - .map((line) => INDENT + line), + ...formFields.flatMap(renderFormField).map((line) => INDENT + line), '}', ]; } @@ -3529,8 +3919,7 @@ function renderFormField(field: FormField): Lines { /** * Bare only for a number or boolean literal that lowers back to the same text. * Operaton evaluates a default as an expression, so anything else bare - * (`maybe`, - * `1 + 1`, `-3`) would change meaning. + * (`maybe`, `1 + 1`, `-3`) would change meaning. */ function renderFormDefault(value: string, type: FormFieldType): string { const bare = @@ -3595,7 +3984,7 @@ function renderFormValue(value: FormFieldValue): string { */ function propertyMembers(properties: ExtensionProperty[] | undefined): Lines[] { return (properties ?? []).map((p) => [ - `${PROPERTY_DIRECTION} ${p.key} = ${quote(p.value)}`, + `${PROPERTY_DIRECTION} ${printName(p.key)} = ${quote(p.value)}`, ]); } @@ -3605,7 +3994,7 @@ function errorMappingMembers( names: PrintNames, ): Lines[] { return (mappings ?? []).map((m) => [ - `${ERROR_MAPPING_HEAD} ${names.error.get(m.errorCode) ?? m.errorCode} ${ERROR_MAPPING_WHEN} ${renderRawCondition(m.condition)}`, + `${ERROR_MAPPING_HEAD} ${names.error.get(m.errorCode) ?? printName(m.errorCode)} ${ERROR_MAPPING_WHEN} ${renderRawCondition(m.condition)}`, ]); } @@ -3621,7 +4010,7 @@ function renderReceiveTask( ...engineSettings(el), ]; return bracketed( - `receive ${nameOf(names, el.id)}${repeatClause(el)}`, + `receive ${printName(el.id)}${repeatClause(el)}`, settings, structuredMembers(el), ); @@ -3646,7 +4035,7 @@ function renderServiceTask( ...engineSettings(el), ]; return bracketed( - `${keyword} ${nameOf(names, el.id)}${repeatClause(el)}`, + `${keyword} ${printName(el.id)}${repeatClause(el)}`, settings, [ ...fieldMembers(binding), @@ -3763,7 +4152,7 @@ function renderCallActivity( } return bracketed( - `call ${nameOf(names, el.id)}${repeatClause(el)}`, + `call ${printName(el.id)}${repeatClause(el)}`, settings, members, ); @@ -3823,7 +4212,7 @@ function renderScriptTask( ...engineSettings(el), ]; const lines = bracketed( - `script ${nameOf(names, el.id)}${repeatClause(el)}`, + `script ${printName(el.id)}${repeatClause(el)}`, settings, structuredMembers(el), ); @@ -3834,9 +4223,7 @@ function renderScriptTask( function namedSettings(el: Named): string[] { return [ ...(el.name === undefined ? [] : [setting('label', quoteLiteral(el.name))]), - ...(el.documentation === undefined - ? [] - : [setting('documentation', quoteLiteral(el.documentation))]), + ...documentationSetting(DOCUMENTATION_KEY, el.documentation), ]; } @@ -3854,6 +4241,16 @@ function bodyHeader(head: string, settings: string[], members: Lines[]): Lines { return lines; } +function pushBlock( + lines: string[], + header: Lines, + body: readonly string[], +): void { + lines.push(...header); + for (const l of body) lines.push(INDENT + l); + lines.push('}'); +} + function withMembers(head: string, members: Lines[]): Lines { if (members.length === 0) return [head]; return [`${head} {`, ...members.flat().map((line) => INDENT + line), '}']; @@ -3875,7 +4272,7 @@ export const BARE_CARDINALITY = /^\d+$/; */ function codeDeclarations(process: BpmnProcess): { lines: string[]; - names: Pick; + names: PrintNames; } { const names = { error: new Map(), @@ -3889,9 +4286,9 @@ function codeDeclarations(process: BpmnProcess): { decl: { name?: string; code: string; message?: string }, ): void => { const name = claimDeclarationName(decl.code, taken, decl.name); - names[kind].set(decl.code, name); + names[kind].set(decl.code, printName(name)); lines.push( - `${kind} ${name}` + + `${kind} ${printName(name)}` + parens([ ...(decl.code === name ? [] @@ -3956,7 +4353,7 @@ function variableDecls(process: BpmnProcess): string[] { anyRepeats = true; if (loop.elementVariable !== undefined) typed.add(loop.elementVariable); read(loop.cardinality); - if (loop.collection !== undefined && ID_TEXT.test(loop.collection)) { + if (loop.collection !== undefined && isPlainName(loop.collection)) { reads.add(loop.collection); } read(loop.completionCondition); @@ -4032,26 +4429,53 @@ function repeatClause(el: Repeatable): string { parts.push( BARE_CARDINALITY.test(loop.cardinality) ? loop.cardinality - : renderRawCondition(loop.cardinality), + : cardinalityValue(loop.cardinality), ); } if (loop.collection !== undefined) { const element = loop.elementVariable === undefined ? '' : `${loop.elementVariable} `; - const collection = ID_TEXT.test(loop.collection) + const collection = isPlainName(loop.collection) ? loop.collection : quote(loop.collection); parts.push(`each ${element}in ${collection}`); } if (loop.sequential === true) parts.push('sequentially'); if (loop.completionCondition !== undefined) { - parts.push(`until (${renderRawCondition(loop.completionCondition)})`); + parts.push( + `until (${documentedCondition(loop.completionCondition, loop.completionConditionDocumentation)})`, + ); } return ` for ${parts.join(' ')}`; } +/** + * Text around a template is one composite expression, evaluated to text the + * engine reads as an integer. Quoting keeps it verbatim, where wrapping it as a + * raw body would nest one '${' inside another. + */ +function cardinalityValue(body: string): string { + return singleTemplateBody(body.trim()) === undefined + ? quote(body) + : renderRawCondition(body); +} + +/** The text inside a condition head's parentheses. */ function renderCondition(flow: SequenceFlow): string { - return renderRawCondition(flow.conditionExpression ?? ''); + return documentedCondition( + flow.conditionExpression ?? '', + flow.conditionDocumentation, + ); +} + +function documentedCondition( + body: string, + documentation: string | undefined, +): string { + return [ + renderRawCondition(body), + ...documentationSetting(VALUE_DOCUMENTATION_KEYS.condition, documentation), + ].join(', '); } function renderRawCondition(body: string): string { diff --git a/packages/transform/src/ir-to-xml.ts b/packages/transform/src/ir-to-xml.ts index db4eb88..24b1afe 100644 --- a/packages/transform/src/ir-to-xml.ts +++ b/packages/transform/src/ir-to-xml.ts @@ -24,7 +24,7 @@ import { layoutProcess } from 'bpmn-auto-layout'; import { humanize } from './humanize.js'; import { LayoutError } from './errors.js'; -import { resolveCollision } from './synthesize-ids.js'; +import { claimId, resolveCollision } from './synthesize-ids.js'; import type { BpmnProcess, CallActivity, @@ -65,13 +65,15 @@ import { */ const TARGET_NAMESPACE = 'http://bpmnscript.io/processes'; -/** Written when a process authors none; the importer drops exactly this value. */ +/** Written when a process authors none; the printer omits exactly this value. */ export const HISTORY_TIME_TO_LIVE = 'P30D'; /** Read, not imported `with { type: 'json' }`, which needs `resolveJsonModule`. */ -const operatonModdleExtension: unknown = JSON.parse( +const operatonModdleExtension = JSON.parse( readFileSync(resolveOperatonModdlePath(), 'utf-8'), -); +) as { uri: string }; + +export const OPERATON_NS = operatonModdleExtension.uri; function resolveOperatonModdlePath(): string { const moduleDir = dirname(fileURLToPath(import.meta.url)); @@ -97,7 +99,7 @@ function resolveOperatonModdlePath(): string { */ export function createModdle(): BpmnModdleInstance { return new BpmnModdle({ - operaton: operatonModdleExtension as Record, + operaton: operatonModdleExtension, }); } @@ -171,7 +173,7 @@ export async function irToXml( } try { - return escapeAttributeWhitespace(await layoutProcess(xml)); + return writtenXml(await layoutProcess(xml)); } catch (cause) { // The expansion hint has no `dc:Bounds`, and `BpmnParse.parseDIBounds` fails // deployment on a bounds-less shape, so the fallback drops the diagram. @@ -179,10 +181,36 @@ export async function irToXml( const { xml: xmlWithoutDiagram } = await moddle.toXML(definitions, { format: true, }); - throw new LayoutError(escapeAttributeWhitespace(xmlWithoutDiagram), cause); + throw new LayoutError(writtenXml(xmlWithoutDiagram), cause); } } +function writtenXml(xml: string): string { + return tightenDocumentedExpressions(escapeAttributeWhitespace(xml)); +} + +/** + * The formatter indents a documentation child, and the indentation lands in + * the expression's text. `BpmnParse` reads a loop count and a completion + * condition untrimmed, so that whitespace turns either into text the engine + * fails on; the child and the body are written flush instead. + */ +function tightenDocumentedExpressions(xml: string): string { + const tags = [ + 'conditionExpression', + 'condition', + 'loopCardinality', + 'completionCondition', + ...Object.values(TIMER_KIND_TO_CHILD), + ].join('|'); + const documented = new RegExp( + `(]*>)\\s*([^<]*?)\\s*([^<]*)\\s*([^<]*?)\\s*()`, + 'g', + ); + // The body sits on either side of the child, depending on the writer path. + return xml.replace(documented, '$1$4$3$5$6'); +} + /** * moddle-xml escapes a line feed in an attribute value but writes a tab or a * carriage return raw, and XML attribute-value normalization turns either into @@ -288,12 +316,7 @@ function synthesizeNamedRoots( } function claimRootId(prefix: string, code: string, taken: Set): string { - const id = resolveCollision( - prefix + code.replace(/[^A-Za-z0-9_.-]/g, '_'), - taken, - ); - taken.add(id); - return id; + return claimId(prefix + code.replace(/[^A-Za-z0-9_.-]/g, '_'), taken); } function collectElementIds(container: FlowContainer, into: Set): void { @@ -343,12 +366,6 @@ function serviceTaskBindingAttrs( }; case 'builtin': return { 'operaton:type': binding.type }; - default: { - const exhaustive: never = binding; - throw new Error( - `Unhandled ServiceTaskBinding kind: ${JSON.stringify(exhaustive)}`, - ); - } } } @@ -432,11 +449,18 @@ function definitionAttrs( [TIMER_KIND_TO_CHILD[def.timerKind]]: formalExpression( moddle, def.expression, + def.expressionDocumentation, ), ...(owner.exclusive === false ? { 'operaton:exclusive': false } : {}), }; case 'conditional': - return { condition: formalExpression(moddle, def.condition) }; + return { + condition: formalExpression( + moddle, + def.condition, + def.conditionDocumentation, + ), + }; case 'link': // The engine correlates link throw and catch by `name` alone; `source`/`target` go unread. return { name: def.linkName }; @@ -444,12 +468,6 @@ function definitionAttrs( case 'terminate': case 'cancel': return {}; - default: { - const exhaustive: never = def; - throw new Error( - `Unhandled EventDefinition kind: ${JSON.stringify(exhaustive)}`, - ); - } } } @@ -467,8 +485,12 @@ function eventDefinitionAttrs( function formalExpression( moddle: BpmnModdleInstance, body: string, + documentation?: string, ): ModdleElement { - return moddle.create('bpmn:FormalExpression', { body }); + return moddle.create('bpmn:FormalExpression', { + body, + ...documentationChild(moddle, documentation), + }); } export const TIMER_KIND_TO_CHILD: Record< @@ -832,17 +854,32 @@ function loopCharacteristicsAttrs( if (loop.collection !== undefined) { attrs['operaton:collection'] = loop.collection; } - if (loop.elementVariable !== undefined) { + // The engine reads both into one field; only the data item has room for + // documentation. + if ( + loop.elementVariable !== undefined && + loop.elementVariableDocumentation !== undefined + ) { + attrs.inputDataItem = moddle.create('bpmn:DataInput', { + name: loop.elementVariable, + ...documentationChild(moddle, loop.elementVariableDocumentation), + }); + } else if (loop.elementVariable !== undefined) { attrs['operaton:elementVariable'] = loop.elementVariable; } Object.assign(attrs, bareJobSettingAttrs(moddle, loop)); if (loop.cardinality !== undefined) { - attrs.loopCardinality = formalExpression(moddle, loop.cardinality); + attrs.loopCardinality = formalExpression( + moddle, + loop.cardinality, + loop.cardinalityDocumentation, + ); } if (loop.completionCondition !== undefined) { attrs.completionCondition = formalExpression( moddle, loop.completionCondition, + loop.completionConditionDocumentation, ); } return { @@ -896,7 +933,7 @@ function engineSettingAttrs( if (isGateway(node)) { return bareJobSettingAttrs(moddle, node); } - const attrs: Record = jobSettingAttrs(node); + const attrs = jobSettingAttrs(node); const extensionElements = buildExtensionElements(moddle, node, roots); if (extensionElements !== undefined) { attrs.extensionElements = extensionElements; @@ -955,8 +992,7 @@ function buildExtensionElements( let id: string | undefined; if (listener.event === 'timeout') { timeouts += 1; - id = resolveCollision(`${node.id}_timeout_${timeouts}`, roots.taken); - roots.taken.add(id); + id = claimId(`${node.id}_timeout_${timeouts}`, roots.taken); } values.push( buildListener(moddle, 'operaton:TaskListener', listener, roots, id), @@ -1109,12 +1145,6 @@ function listenerBindingAttrs( return { delegateExpression: binding.expression }; case 'script': return { script: buildScript(moddle, binding) }; - default: { - const exhaustive: never = binding; - throw new Error( - `Unhandled ListenerBinding kind: ${JSON.stringify(exhaustive)}`, - ); - } } } @@ -1244,30 +1274,19 @@ function buildCallExtensionValues( function callMappingAttrs( mapping: CallVariableMapping, ): Record { - const attrs: Record = {}; + const local = mapping.local === true ? { local: true } : {}; switch (mapping.kind) { case 'all': - attrs.variables = 'all'; - break; + return { variables: 'all', ...local }; case 'variable': - attrs.source = mapping.source; - attrs.target = mapping.target; - break; + return { source: mapping.source, target: mapping.target, ...local }; case 'expression': - attrs.sourceExpression = mapping.sourceExpression; - attrs.target = mapping.target; - break; - default: { - const exhaustive: never = mapping; - throw new Error( - `Unhandled CallVariableMapping kind: ${JSON.stringify(exhaustive)}`, - ); - } - } - if (mapping.local === true) { - attrs.local = true; + return { + sourceExpression: mapping.sourceExpression, + target: mapping.target, + ...local, + }; } - return attrs; } /** @@ -1295,6 +1314,7 @@ function createSequenceFlow( attrs.conditionExpression = formalExpression( moddle, flow.conditionExpression, + flow.conditionDocumentation, ); } return moddle.create('bpmn:SequenceFlow', attrs); @@ -1328,6 +1348,11 @@ function attachDefaultFlows( const defaultFlowId = 'defaultFlowId' in node ? node.defaultFlowId : undefined; if (defaultFlowId === undefined) continue; + if ('loop' in node && repeats(node.loop)) { + throw new Error( + `${node.kind} "${node.id}" is repeated and declares default flow "${defaultFlowId}", which the engine can never take.`, + ); + } const owner = requireById(flowNodeById, node.id); const defaultFlow = sequenceFlowById.get(defaultFlowId); if (defaultFlow === undefined) { diff --git a/packages/transform/src/ir/types.ts b/packages/transform/src/ir/types.ts index a7b4d93..8220cec 100644 --- a/packages/transform/src/ir/types.ts +++ b/packages/transform/src/ir/types.ts @@ -33,7 +33,10 @@ export interface BpmnProcess extends FlowContainer, Named { isExecutable: true; /** Distinct from the engine's deployment version. */ versionTag?: string; - /** Absent means the exporter's default; see `HISTORY_TIME_TO_LIVE`. */ + /** + * Absent only for an imported process that sets none; the exporter then + * writes `HISTORY_TIME_TO_LIVE`. + */ historyTimeToLive?: string; /** * Comma-separated; stored as written and not checked when an instance starts. @@ -250,11 +253,14 @@ export type EventDefinition = timerKind: TimerKind; /** ISO-8601 or EL, verbatim. */ expression: string; + /** The `` inside the time element. */ + expressionDocumentation?: string; } | { kind: 'conditional'; /** Raw `${...}` body. */ condition: string; + conditionDocumentation?: string; } | { kind: 'link'; @@ -373,10 +379,14 @@ export function ioMapped( */ export interface LoopCharacteristics extends Omit { cardinality?: string; + cardinalityDocumentation?: string; /** A variable name, or an expression when it carries `${`. */ collection?: string; elementVariable?: string; + /** Written on a `bpmn:inputDataItem` named after `elementVariable`. */ + elementVariableDocumentation?: string; completionCondition?: string; + completionConditionDocumentation?: string; /** * Serialized as `isSequential`; absent means parallel, the engine default. */ @@ -390,7 +400,10 @@ export interface Repeatable { export interface Activity extends Repeatable { /** * BPMN `default` on a step: the engine takes it when no other outgoing - * condition held, as on a gateway. + * route was taken, as on an inclusive gateway. On a repeated step the routes + * leave its multi-instance body instead, and the engine fails every run with + * `missingDefaultFlowException`, so the export refuses one there and the + * import keeps it only for the printer, which moves it onto a fork. */ defaultFlowId?: string; } @@ -668,10 +681,6 @@ export function gatewayDefaultFlowId(gateway: Gateway): string | undefined { case 'parallelGateway': case 'eventBasedGateway': return undefined; - default: { - const exhaustive: never = gateway; - throw new Error(`Unhandled gateway kind: ${JSON.stringify(exhaustive)}`); - } } } @@ -751,4 +760,5 @@ export interface SequenceFlow { targetRef: string; /** Includes the wrapper, e.g. `"${amount > 1000}"`. */ conditionExpression?: string; + conditionDocumentation?: string; } diff --git a/packages/transform/src/juel.ts b/packages/transform/src/juel.ts index c8f1e03..de9689d 100644 --- a/packages/transform/src/juel.ts +++ b/packages/transform/src/juel.ts @@ -10,7 +10,7 @@ * to itself. */ -import { ID_TERMINAL } from '@bpmn-script/language'; +import { ID_TERMINAL, isPlainName } from '@bpmn-script/language'; export type JuelNode = | { kind: 'int'; value: number } @@ -186,6 +186,10 @@ function tokenize(input: string): Token[] | undefined { tokens.push({ type: 'bool', value: word }); } else if (word === 'null') { tokens.push({ type: 'null', value: word }); + } else if (!isPlainName(word)) { + // The grammar lexes a keyword as itself, so `var == 1` or `a.end` + // would not parse back; the quoted raw form spells it. + return undefined; } else { tokens.push({ type: 'id', value: word }); } @@ -244,7 +248,17 @@ function readString( return undefined; } -/** Binary levels are left-associative; {@link ParseError} becomes a raw result. */ +/** Loosest first; every level is left-associative. */ +const BINARY_LEVELS: readonly (readonly BinaryOp[])[] = [ + ['||'], + ['&&'], + ['==', '!='], + ['<=', '>=', '<', '>'], + ['+', '-'], + ['*', '/', '%'], +]; + +/** A {@link ParseError} becomes a raw result. */ class Parser { private pos = 0; @@ -255,58 +269,30 @@ class Parser { } parseExpr(): JuelNode { - return this.parseTernary(); - } - - private parseTernary(): JuelNode { - const condition = this.parseLogicalOr(); + const condition = this.parseBinary(0); if (this.matchOp('?')) { - const whenTrue = this.parseLogicalOr(); + const whenTrue = this.parseBinary(0); this.expectOp(':'); - const whenFalse = this.parseLogicalOr(); + const whenFalse = this.parseBinary(0); return { kind: 'ternary', condition, whenTrue, whenFalse }; } return condition; } - private parseLogicalOr(): JuelNode { - return this.parseBinaryLevel(['||'], () => this.parseLogicalAnd()); - } - - private parseLogicalAnd(): JuelNode { - return this.parseBinaryLevel(['&&'], () => this.parseEquality()); - } - - private parseEquality(): JuelNode { - return this.parseBinaryLevel(['==', '!='], () => this.parseRelational()); - } - - private parseRelational(): JuelNode { - return this.parseBinaryLevel(['<=', '>=', '<', '>'], () => - this.parseAdditive(), - ); - } - - private parseAdditive(): JuelNode { - return this.parseBinaryLevel(['+', '-'], () => this.parseMultiplicative()); - } - - private parseMultiplicative(): JuelNode { - return this.parseBinaryLevel(['*', '/', '%'], () => this.parseUnary()); - } - - private parseBinaryLevel(ops: BinaryOp[], operand: () => JuelNode): JuelNode { - let left = operand(); - for (;;) { - const op = this.peekOp(); - if (op !== undefined && (ops as string[]).includes(op)) { - this.pos++; - const right = operand(); - left = { kind: 'binary', op: op as BinaryOp, left, right }; - } else { - return left; - } + private parseBinary(level: number): JuelNode { + if (level === BINARY_LEVELS.length) return this.parseUnary(); + const ops: readonly string[] = BINARY_LEVELS[level]!; + let left = this.parseBinary(level + 1); + for ( + let op = this.peekOp(); + op !== undefined && ops.includes(op); + op = this.peekOp() + ) { + this.pos++; + const right = this.parseBinary(level + 1); + left = { kind: 'binary', op: op as BinaryOp, left, right }; } + return left; } private parseUnary(): JuelNode { diff --git a/packages/transform/src/synthesize-ids.ts b/packages/transform/src/synthesize-ids.ts index 548ce8e..6ee2e05 100644 --- a/packages/transform/src/synthesize-ids.ts +++ b/packages/transform/src/synthesize-ids.ts @@ -4,12 +4,6 @@ * one breaks round-trip stability. */ -import { - BpmnScriptGrammar, - ID_TEXT, - reservedWordsOf, -} from '@bpmn-script/language'; - const START_EVENT_PREFIX = 'StartEvent_'; const END_EVENT_PREFIX = 'EndEvent_'; export const THROW_EVENT_PREFIX = 'Throw_'; @@ -113,31 +107,21 @@ export function claimId(base: string, taken: Set): string { return id; } -export function isWritableName(word: string): boolean { - return ID_TEXT.test(word) && !reservedWordsOf(BpmnScriptGrammar()).has(word); -} - /** - * Claimed in `taken`: codes differing only in punctuation mint the same name, - * which would make every use site ambiguous. + * A name in backticks spells any text on one line, so the preferred name stays + * as written. Claimed in `taken`: two codes can prefer one name, which would + * make every use site ambiguous. */ export function claimDeclarationName( code: string, taken: Set, preferred?: string, ): string { - const base = - preferred !== undefined && isWritableName(preferred) - ? preferred - : mintPrintableName(code); - return claimId(base, taken); -} - -export function mintPrintableName(text: string): string { - const sanitized = isWritableName(text) ? text : text.replace(/\W/g, '_'); - // Keywords and digit-led words do not lex as `ID`; a leading underscore - // fixes both, and no keyword has one. - return isWritableName(sanitized) ? sanitized : `_${sanitized}`; + const base = [preferred, code].find( + (text) => text !== undefined && /^[^\r\n]+$/.test(text), + ); + // Only a code spanning lines has no one-line spelling. + return claimId(base ?? code.replace(/[\r\n]+/g, ' '), taken); } export function resolveCollision(base: string, taken: Set): string { diff --git a/packages/transform/src/xml-to-ir.ts b/packages/transform/src/xml-to-ir.ts index 7a7f9dd..24f800f 100644 --- a/packages/transform/src/xml-to-ir.ts +++ b/packages/transform/src/xml-to-ir.ts @@ -18,7 +18,8 @@ import { FORM_CONSTRAINT_TYPES, formatPlainWordList, formatWordList, - ID_TEXT, + ENGINE_ID_TEXT, + isPlainName, scriptFormatOf, SHELL_FLAG_FIELDS, SHELL_FLAG_LITERALS, @@ -30,12 +31,9 @@ import { TYPE_BINDING_VALUES, USER_TASK_VERBATIM_KEYS, } from '@bpmn-script/language'; -import type { - BuiltinTaskType, - TimerJobKey, - TimerKind, -} from '@bpmn-script/language'; +import type { BuiltinTaskType, TimerJobKey } from '@bpmn-script/language'; import { Parser } from 'saxen'; +import type { ParseContext } from 'saxen'; import type { BoundaryEvent, BpmnProcess, @@ -91,6 +89,13 @@ import { } from './ir/types.js'; import { + FORM_CONSTRAINT_NAME_LINES, + HANDLER_TRIGGER_NOTE, + oneLine, + PLAIN_NAME_NOTE, + SERVICE_TASK_FORM_LINES, + THROW_CODE_NOTE, + THROWN_MESSAGE_FORM_LINES, UnsupportedAssignmentError, UnsupportedCallActivityError, UnsupportedCollaborationError, @@ -107,6 +112,7 @@ import { UnsupportedFormReferenceError, UnsupportedGatewayShapeError, UnsupportedLoopCharacteristicsError, + UnsupportedVariableNameError, UnsupportedServiceTaskFormError, } from './errors.js'; import { humanize } from './humanize.js'; @@ -125,6 +131,7 @@ import { FORM_FIELD_TYPE_TO_OPERATON, HISTORY_TIME_TO_LIVE, IMPLEMENTATION_ATTRS, + OPERATON_NS, SERVICE_TASK_LIKE_TAG, SUB_PROCESS_LIKE_TAG, TIMER_KIND_TO_CHILD, @@ -134,7 +141,6 @@ import { claimDeclarationName, endIdOf, makeEndEventId, - isWritableName, makeEventSubProcessId, makeSequenceFlowId, makeStartEventId, @@ -263,11 +269,11 @@ function ownedRows( * shape only is reported by hand. */ const CONSUMED_EXTENSION_ATTRS = consumptionTable([ - ['asyncBefore', JOB_SETTING_OWNERS], - ['async', JOB_SETTING_OWNERS], - ['asyncAfter', JOB_SETTING_OWNERS], + ...ownedRows( + ['asyncBefore', 'async', 'asyncAfter', 'jobPriority'], + JOB_SETTING_OWNERS, + ), ['exclusive', [...JOB_SETTING_OWNERS, 'bpmn:TimerEventDefinition']], - ['jobPriority', JOB_SETTING_OWNERS], ...ownedRows( [...USER_TASK_VERBATIM_KEYS, 'formRef', ...FORM_REF_MODIFIER_ATTRS], ['bpmn:UserTask'], @@ -280,18 +286,25 @@ const CONSUMED_EXTENSION_ATTRS = consumptionTable([ ['decisionRef', ...DECISION_MODIFIER_ATTRS], ['bpmn:BusinessRuleTask'], ), - ['calledElementBinding', ['bpmn:CallActivity']], - ['calledElementVersion', ['bpmn:CallActivity']], - ...ownedRows(Object.values(VARIABLE_MAPPING_ATTR_BY_KIND), [ - 'bpmn:CallActivity', - ]), - ['collection', [MULTI_INSTANCE]], - ['elementVariable', [MULTI_INSTANCE]], - ['versionTag', ['bpmn:Process']], - ['historyTimeToLive', ['bpmn:Process']], - ['candidateStarterUsers', ['bpmn:Process']], - ['candidateStarterGroups', ['bpmn:Process']], - ['isStartableInTasklist', ['bpmn:Process']], + ...ownedRows( + [ + 'calledElementBinding', + 'calledElementVersion', + ...Object.values(VARIABLE_MAPPING_ATTR_BY_KIND), + ], + ['bpmn:CallActivity'], + ), + ...ownedRows(['collection', 'elementVariable'], [MULTI_INSTANCE]), + ...ownedRows( + [ + 'versionTag', + 'historyTimeToLive', + 'candidateStarterUsers', + 'candidateStarterGroups', + 'isStartableInTasklist', + ], + ['bpmn:Process'], + ), ['initiator', ['bpmn:StartEvent']], ['errorCodeVariable', ['bpmn:ErrorEventDefinition']], ['errorMessageVariable', ['bpmn:ErrorEventDefinition']], @@ -402,6 +415,15 @@ function isConsumedHere( return table.get(name)?.has(ownerType) === true; } +/** Leaves out every undefined value, so an absent setting writes no key. */ +function defined( + props: T, +): { [K in keyof T]?: Exclude } { + return Object.fromEntries( + Object.entries(props).filter(([, value]) => value !== undefined), + ) as { [K in keyof T]?: Exclude }; +} + function invert( map: Readonly>, ): Readonly> { @@ -425,12 +447,12 @@ const ENGINE_READS_ELSEWHERE: ReadonlyMap = new Map< [ 'throw:bpmn:SignalEventDefinition/async', 'reads it on a thrown signal as its async delivery ' + - '(BpmnParse.parseSignalEventDefinition)', + "('BpmnParse.parseSignalEventDefinition')", ], [ 'throw:bpmn:SignalEventDefinition/operaton:In', 'reads it on a thrown signal as its payload ' + - '(BpmnParse.parseSignalEventDefinition through parseInputParameter)', + "('BpmnParse.parseSignalEventDefinition' through 'parseInputParameter')", ], ...[ 'formKey', @@ -440,43 +462,43 @@ const ENGINE_READS_ELSEWHERE: ReadonlyMap = new Map< 'formHandlerClass', ].map((name): [string, string] => [ `initial:bpmn:StartEvent/${name}`, - "reads it on the process's own start (BpmnParse.parseStartFormHandlers)", + "reads it on the process's own start ('BpmnParse.parseStartFormHandlers')", ]), ...['jobPriority', 'taskPriority'].map((name): [string, string] => [ `bpmn:Process/${name}`, - 'reads it (BpmnParse.parseProcess through parsePriority)', + "reads it ('BpmnParse.parseProcess' through 'parsePriority')", ]), [ 'bpmn:Process/operaton:ExecutionListener', 'runs it on the process instance ' + - '(BpmnParse.parseExecutionListenersOnScope)', + "('BpmnParse.parseExecutionListenersOnScope')", ], [ 'bpmn:SequenceFlow/operaton:ExecutionListener', 'runs it when the flow is taken, whatever its event says ' + - '(BpmnParse.parseExecutionListenersOnTransition)', + "('BpmnParse.parseExecutionListenersOnTransition')", ], ...GATEWAY_TAGS.map((tag): [string, string] => [ `${tag}/operaton:ExecutionListener`, - 'runs it (BpmnParse.parseExecutionListenersOnScope)', + "runs it ('BpmnParse.parseExecutionListenersOnScope')", ]), [ 'bpmn:IntermediateThrowEvent/operaton:InputOutput', - 'reads it on every throw but a link (BpmnParse.parseActivityInputOutput)', + "reads it on every throw but a link ('BpmnParse.parseActivityInputOutput')", ], [ 'bpmn:IntermediateCatchEvent/operaton:InputOutput', - 'reads it (BpmnParse.parseActivityInputOutput)', + "reads it ('BpmnParse.parseActivityInputOutput')", ], [ 'bpmn:EndEvent/operaton:InputOutput', - 'reads its input parameters (BpmnParse.parseActivityInputOutput)', + "reads its input parameters ('BpmnParse.parseActivityInputOutput')", ], ]); const IMPORTED_FLOW_NOTE = - '(this tool imports the executable flow and the engine settings on its ' + - 'steps, and nothing declared or drawn beside it).'; + 'note: BPMNscript imports the executable flow and the engine settings on ' + + 'its steps, and nothing declared or drawn beside it'; interface ModdleElement { readonly $type: string; @@ -510,8 +532,8 @@ interface ModdlePropertyDescriptor { */ const UNRESOLVED_REFS = new WeakMap>(); -function recordUnresolvedRefs(moddleWarnings: unknown): void { - for (const warning of (moddleWarnings as ModdleWarning[] | undefined) ?? []) { +function recordUnresolvedRefs(moddleWarnings: readonly ModdleWarning[]): void { + for (const warning of moddleWarnings) { const { message, element, property, value } = warning; if (message?.startsWith('unresolved reference') !== true) continue; if (element === undefined || property === undefined) continue; @@ -527,16 +549,217 @@ function unresolvedRef(el: ModdleElement, name: string): string | undefined { return UNRESOLVED_REFS.get(el)?.get(`bpmn:${name}`); } +const CAMUNDA_NS = 'http://camunda.org/schema/1.0/bpmn'; /** The same URI inside a documentation body or a script is text. */ -const CAMUNDA_XMLNS = - /(xmlns(?::[\w.-]+)?\s*=\s*)(["'])http:\/\/camunda\.org\/schema\/1\.0\/bpmn\2/g; -const OPERATON_NS = 'http://operaton.org/schema/1.0/bpmn'; +const CAMUNDA_XMLNS = new RegExp( + `(xmlns(?::[\\w.-]+)?\\s*=\\s*)(["'])${CAMUNDA_NS.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')}\\2`, + 'g', +); export const CAMUNDA_ALIAS_MESSAGE = - 'The file declares the camunda namespace; it was read as the ' + - 'operaton namespace, since `BpmnParse.OPERATON_BPMN_EXTENSIONS_NS` ' + - 'falls back to the camunda URI wherever the operaton spelling is ' + - 'absent, and the document written back carries `operaton:` alone.'; + "the file's 'camunda' namespace was read as the 'operaton' namespace\n" + + "note: 'BpmnParse.OPERATON_BPMN_EXTENSIONS_NS' falls back to the " + + "'camunda' URI wherever the 'operaton' spelling is absent\n" + + "note: the rebuilt document carries 'operaton:' alone"; + +export const shadowedSpellingMessage = ( + ownerId: string, + names: readonly string[], +): string => + `${formatPlainWordList( + names.map((name) => `'${name}'`), + 'and', + )} on '${ownerId}' ${names.length === 1 ? 'was' : 'were'} not imported\n` + + "note: the engine reads the 'operaton:' spelling beside " + + `${names.length === 1 ? 'it' : 'them'} and ignores the 'camunda:' one`; + +/** + * Children `BpmnParse` reads by tag alone, whatever their namespace, so both + * spellings run: every child of a list, a map entry or a parameter + * (`BpmnParseUtil.parseNestedParamValueProvider`), a map's `entry`, and an + * external task's `errorEventDefinition`. + */ +const ANY_NAMESPACE_PARENTS = new Set([ + 'list', + 'entry', + 'inputParameter', + 'outputParameter', +]); +const ANY_NAMESPACE_CHILD: Readonly> = { + map: 'entry', + extensionElements: 'errorEventDefinition', +}; + +interface ShadowedSpelling { + start: number; + end: number; + ownerId: string | undefined; + name: string; +} + +interface OpenElement { + readonly ns: ReadonlyMap; + readonly uri: string | undefined; + readonly local: string; + readonly ownerId: string | undefined; + readonly start?: number; + readonly children: { + uri?: string; + local: string; + range?: ShadowedSpelling; + }[]; +} + +/** + * `Element.attributeNS`, `elementNS` and `elementsNS` look under the operaton + * URI first and under the camunda URI only when that finds nothing, per + * attribute name on one element and per child tag name under one parent. The + * camunda spellings they never reach are cut from the text before the URI + * swap merges the two namespaces, so the import reads what the engine runs. + */ +function cutShadowedCamundaSpellings(xml: string): { + xml: string; + shadowed: readonly ShadowedSpelling[]; +} { + if (!xml.includes(CAMUNDA_NS) || !xml.includes(OPERATON_NS)) { + return { xml, shadowed: [] }; + } + let lineStarts: number[] | undefined; + // saxen's getContext rescans from the start of the text, so this + // is quadratic in the camunda: elements of a document declaring both URIs. + const offsetOf = (getContext: () => ParseContext): number => { + lineStarts ??= [ + 0, + ...Array.from(xml.matchAll(/\r\n|\r|\n/g), (m) => m.index + m[0].length), + ]; + const { line, column } = getContext(); + return lineStarts[line]! + column; + }; + + const shadowed: ShadowedSpelling[] = []; + const stack: OpenElement[] = []; + const parser = new Parser(); + parser.on( + 'openTag', + (elementName, getAttrs, _decodeEntities, _selfClosing, getContext) => { + const parent = stack.at(-1); + const attrs = getAttrs(); + const ns = new Map(parent?.ns); + for (const [key, value] of Object.entries(attrs)) { + if (key === 'xmlns') ns.set('', value); + else if (key.startsWith('xmlns:')) ns.set(key.slice(6), value); + } + const colon = elementName.indexOf(':'); + const uri = ns.get(colon < 0 ? '' : elementName.slice(0, colon)); + const ownerId = attrs['id'] ?? parent?.ownerId; + + const byUri = (wanted: string): Map => + new Map( + Object.keys(attrs) + .filter((key) => !key.startsWith('xmlns') && key.includes(':')) + .filter((key) => ns.get(key.slice(0, key.indexOf(':'))) === wanted) + .map((key) => [localNameOf(key), key]), + ); + const operatonAttrs = byUri(OPERATON_NS); + const ignoredAttrs = [...byUri(CAMUNDA_NS)] + .filter(([local]) => operatonAttrs.has(local)) + .map(([, key]) => key); + const start = + uri === CAMUNDA_NS || ignoredAttrs.length > 0 + ? offsetOf(getContext) + : undefined; + if (ignoredAttrs.length > 0) { + const attribute = /\s+([^\s=]+)\s*=\s*(?:"[^"]*"|'[^']*')/y; + const text = getContext().data; + attribute.lastIndex = text.search(/[\s/>]/); + for (let m; (m = attribute.exec(text)) !== null;) { + if (!ignoredAttrs.includes(m[1]!)) continue; + shadowed.push({ + start: start! + m.index, + end: start! + attribute.lastIndex, + ownerId, + name: m[1]!, + }); + } + } + stack.push({ + ns, + uri, + local: localNameOf(elementName), + ownerId, + start, + children: [], + }); + }, + ); + parser.on( + 'closeTag', + (elementName, _decodeEntities, selfClosing, getContext) => { + const el = stack.pop()!; + const parent = stack.at(-1); + parent?.children.push({ + uri: el.uri, + local: el.local, + range: + el.start === undefined + ? undefined + : { + start: el.start, + end: selfClosing + ? el.start + getContext().data.length + : offsetOf(getContext) + getContext().data.length, + ownerId: parent.ownerId, + name: `<${elementName}>`, + }, + }); + if (ANY_NAMESPACE_PARENTS.has(el.local)) return; + const operatonTags = new Set( + el.children.filter((c) => c.uri === OPERATON_NS).map((c) => c.local), + ); + for (const child of el.children) { + if (child.uri !== CAMUNDA_NS || !operatonTags.has(child.local)) + continue; + if (ANY_NAMESPACE_CHILD[el.local] === child.local) continue; + shadowed.push(child.range!); + } + }, + ); + try { + parser.parse(xml); + } catch { + // Malformed XML: cut nothing, so moddle reports the parse error as it + // does for a document declaring one URI. + return { xml, shadowed: [] }; + } + + // A spelling inside one already cut goes with it and needs no warning. + const cut: ShadowedSpelling[] = []; + let kept = ''; + let from = 0; + for (const s of shadowed.sort((a, b) => a.start - b.start)) { + if (s.start < from) continue; + cut.push(s); + kept += xml.slice(from, s.start); + from = s.end; + } + return { xml: kept + xml.slice(from), shadowed: cut }; +} + +function warnShadowedSpellings( + shadowed: readonly ShadowedSpelling[], + processId: string, + warnings: ImportWarning[], +): void { + const byOwner = Map.groupBy(shadowed, (s) => s.ownerId ?? processId); + for (const [ownerId, spellings] of byOwner) { + const names = [...new Set(spellings.map((s) => s.name))]; + warnings.push({ + elementId: ownerId, + category: 'extensionAttribute', + message: shadowedSpellingMessage(ownerId, names), + }); + } +} export async function xmlToIr( xml: string, @@ -584,25 +807,28 @@ async function importDocument( // Operaton reads `camunda:` as `operaton:`. moddle binds prefixes by URI, so // rewriting the declared URI (never the prefix, which can recur in attribute // values) retypes every node. - const normalizedXml = xml.replace(CAMUNDA_XMLNS, `$1$2${OPERATON_NS}$2`); - const camundaRead = normalizedXml !== xml; + const { xml: engineRead, shadowed } = cutShadowedCamundaSpellings(xml); + const normalizedXml = engineRead.replace( + CAMUNDA_XMLNS, + `$1$2${OPERATON_NS}$2`, + ); + const camundaRead = normalizedXml !== engineRead; // moddle reports "unparsable content" only for undeclared `operaton:` // elements. - const { - rootElement, - warnings: moddleWarnings, - elementsById, - } = await moddle.fromXML(normalizedXml); + const parsed = await moddle.fromXML(normalizedXml); + const { rootElement, elementsById } = parsed; + const moddleWarnings = (parsed.warnings ?? []) as ModdleWarning[]; const root = rootElement as ModdleElement; if (root.$type !== 'bpmn:Definitions') { throw new Error( - `Expected root element 'bpmn:Definitions', got '${root.$type}'.`, + `expected the root element 'bpmn:Definitions', got '${root.$type}'`, ); } refuseDroppedIds(moddleWarnings); + refuseUndeployableIds(Object.keys(elementsById)); refuseImports(root); nameAnonymousElements(root, new Set(Object.keys(elementsById)), minted); @@ -622,6 +848,7 @@ async function importDocument( message: CAMUNDA_ALIAS_MESSAGE, }); } + warnShadowedSpellings(shadowed, mappedProcess.id, warnings); // An error and an escalation declaration share one name scope. const declaredNames = new Set(); @@ -681,17 +908,18 @@ function refuseDocumentShapes(xml: string): void { const text = xml.replace(/|/g, ''); if (/ declaration, which Operaton refuses to deploy ' + - '(`Parser.setXxeProcessing` sets disallow-doctype-decl unless XXE ' + - 'processing is enabled) and whose entities this tool does not expand', + "it carries a '' declaration", + "note: Operaton refuses to deploy it, since 'Parser.setXxeProcessing' " + + "sets 'disallow-doctype-decl' unless XXE processing is enabled", + 'note: BPMNscript does not expand its entities', ); } const entity = UNDEFINED_ENTITY.exec(text); if (entity !== null) { throw new UnsupportedDocumentError( - `it references the entity '${entity[0]}', which XML does not predefine ` + - "and no DOCTYPE declares, so Operaton's parse fails on it and this " + - 'tool would keep it as literal text', + `it references the entity '${entity[0]}'`, + "note: XML does not predefine it and no DOCTYPE declares it, so Operaton's parse fails on it", + 'note: BPMNscript would keep it as literal text', ); } for (const [, name, , value] of text.matchAll( @@ -699,10 +927,11 @@ function refuseDocumentShapes(xml: string): void { )) { if (value === 'true' || value === 'false') continue; throw new UnsupportedDocumentError( - `${name}="${value}" is outside true and false; this tool reads the ` + - "schema's boolean, while Operaton's validating parse (`Parse." + - `execute\` against \`BPMN20.xsd\`, which types \`${name}\` ` + - '`xsd:boolean`) refuses the deployment before `BpmnParse` reads it', + `${name} is '${value}', which is neither 'true' nor 'false'`, + "note: BPMNscript reads the schema's boolean", + "note: Operaton's validating parse ('Parse.execute' against 'BPMN20.xsd', " + + `which types '${name}' as 'xsd:boolean') refuses the deployment ` + + "before 'BpmnParse' reads it", ); } } @@ -711,24 +940,38 @@ function refuseDocumentShapes(xml: string): void { * moddle drops an element with a repeated or non-ASCII id. The engine refuses a * duplicate `xs:ID` but deploys a non-ASCII NCName this tool cannot read. */ -function refuseDroppedIds(moddleWarnings: unknown): void { - for (const warning of (moddleWarnings as ModdleWarning[] | undefined) ?? []) { +function refuseDroppedIds(moddleWarnings: readonly ModdleWarning[]): void { + for (const warning of moddleWarnings) { const match = /nested error: (duplicate|illegal) ID <([^>]+)>/.exec( String(warning.message ?? ''), ); if (match === null) continue; const [, kind, id] = match; - throw new UnsupportedDocumentError( - kind === 'duplicate' - ? `the id '${id}' is written on two elements; Operaton validates ` + - 'every file against BPMN20.xsd and refuses a duplicate xs:ID' - : `the id '${id}' is outside what this tool reads: ASCII letters, ` + - "digits, '_', '-' and '.', starting with a letter or '_', " + - 'where the schema admits any letter; rename it', - ); + throw kind === 'duplicate' + ? new UnsupportedDocumentError( + `the id '${id}' is written on two elements`, + "note: Operaton validates every file against 'BPMN20.xsd' and refuses a duplicate 'xs:ID'", + ) + : new UnsupportedDocumentError( + `the id '${id}' is outside what BPMNscript reads`, + "note: BPMNscript reads ASCII letters, digits, '_', '-' and '.', starting with a letter or '_'", + 'note: the schema admits any letter', + 'help: rename it', + ); } } +/** moddle reads a `prefix:` id as a QName, which `xsd:ID` refuses. */ +function refuseUndeployableIds(ids: readonly string[]): void { + const id = ids.find((candidate) => !ENGINE_ID_TEXT.test(candidate)); + if (id === undefined) return; + throw new UnsupportedDocumentError( + `the id '${id}' is not an id Operaton deploys`, + "note: Operaton validates every file against 'BPMN20.xsd', which types an id as 'xs:ID', and an 'xs:ID' holds no ':'", + 'help: rename it', + ); +} + /** * `BpmnParse.parseImports` fails on every `importType` but WSDL, and on WSDL * without `CxfWSDLImporter`. @@ -737,9 +980,9 @@ function refuseImports(root: ModdleElement): void { const [theImport] = root.get('imports') as ModdleElement[]; if (theImport === undefined) return; throw new UnsupportedDocumentError( - `it declares a bpmn:import of type '${readString(theImport, 'importType') ?? ''}'; ` + - '`BpmnParse.parseImports` fails the deployment on every import type ' + - 'but WSDL, and on WSDL without CxfWSDLImporter on the classpath', + `it declares a 'bpmn:import' of type '${readString(theImport, 'importType') ?? ''}'`, + "note: 'BpmnParse.parseImports' fails the deployment on every import type " + + "but WSDL, and on WSDL without 'CxfWSDLImporter' on the classpath", ); } @@ -753,9 +996,7 @@ function selectProcess( ): ModdleElement { const processes = rootElements.filter((e) => e.$type === 'bpmn:Process'); if (processes.length === 0) { - throw new UnsupportedDocumentError( - 'it holds no bpmn:process, so there is nothing to import', - ); + throw new UnsupportedDocumentError("it holds no 'bpmn:process'"); } const executable = processes.filter((p) => p.get('isExecutable') === true); if (executable.length > 1) { @@ -767,9 +1008,8 @@ function selectProcess( if (executable.length === 0) { if (processes.length > 1) { throw new UnsupportedDocumentError( - `none of its ${processes.length} processes is marked ` + - 'isExecutable="true"; `BpmnParse.parseProcessDefinitions` deploys ' + - 'none of them in a new deployment', + `none of its ${processes.length} processes is marked 'isExecutable="true"'`, + "note: 'BpmnParse.parseProcessDefinitions' deploys none of them in a new deployment", ); } return processes[0]; @@ -781,8 +1021,8 @@ function selectProcess( elementId: skipped.id ?? selected.id ?? '', category: 'unmappedConstruct', message: - `The process '${skipped.id}' is not marked executable and was not ` + - 'imported; `BpmnParse.parseProcessDefinitions` does not deploy it either.', + `process '${skipped.id}' is not marked executable and was not imported\n` + + "note: 'BpmnParse.parseProcessDefinitions' does not deploy it either", }); } return selected; @@ -803,6 +1043,7 @@ function warnCollaborationDrops( for (const p of collab.get('participants') as ModdleElement[]) { const id = p.id ?? collabId; const ref = getEl(p, 'processRef'); + const poolName = readString(p, 'name'); const names = ref === processEl ? `the imported process '${processEl.id}'` @@ -813,9 +1054,10 @@ function warnCollaborationDrops( elementId: id, category: 'unmappedConstruct', message: - `The pool ${describePoolName(p)}(${id}) names ${names}; ` + - '`BpmnParse.parseCollaboration` records it for the diagram alone, ' + - 'and the document written back has no pool.', + `pool ${poolName === undefined ? '' : `'${poolName}' `}(${id}) was not imported\n` + + `note: it names ${names}\n` + + "note: 'BpmnParse.parseCollaboration' records it for the diagram alone\n" + + 'note: the rebuilt document has no pool', }); } for (const flow of collab.get('messageFlows') as ModdleElement[]) { @@ -826,20 +1068,15 @@ function warnCollaborationDrops( elementId: id, category: 'unmappedConstruct', message: - `The message flow '${id}' from '${end('sourceRef')}' to ` + - `'${end('targetRef')}' was not imported; \`BpmnParse\` reads no ` + - 'message flow, so the process runs identically without it.', + `message flow '${id}' from '${end('sourceRef')}' to ` + + `'${end('targetRef')}' was not imported\n` + + "note: 'BpmnParse' reads no message flow, so the process runs the same without it", }); } collectUnmappedBpmnDrops(collab, collabId, warnings); } } -function describePoolName(participant: ModdleElement): string { - const name = readString(participant, 'name'); - return name === undefined ? '' : `'${name}' `; -} - function refuseDuplicateSignalNames(signalRoots: ModdleElement[]): void { const byName = new Map(); for (const root of signalRoots) { @@ -850,16 +1087,17 @@ function refuseDuplicateSignalNames(signalRoots: ModdleElement[]): void { if (prior !== undefined) { throw new UnsupportedEventFeatureError( rootId, - `signal roots '${prior}' and '${rootId}' both declare the name ` + - `"${name}"; \`BpmnParse.parseSignals\` fails the deployment on a ` + - 'duplicate signal name', - 'Leave one root per signal name.', + `signal roots '${prior}' and '${rootId}' both declare the name '${name}'`, + "note: 'BpmnParse.parseSignals' fails the deployment on a duplicate signal name", + 'help: leave one root per signal name', ); } byName.set(name, rootId); } } +type CodeDecl = NonNullable[number]; + /** * Deduped by code. Roots sharing a code but not `operaton:errorMessage` refuse: * usage cannot recover that text. @@ -869,12 +1107,9 @@ function readCodeDecls( label: 'error' | 'escalation', takenNames: Set, warnings: ImportWarning[], -): { name: string; code: string; message?: string }[] { - const seen = new Map< - string, - { decl: { name: string; code: string; message?: string }; rootId: string } - >(); - const decls: { name: string; code: string; message?: string }[] = []; +): CodeDecl[] { + const seen = new Map(); + const decls: CodeDecl[] = []; for (const root of roots) { const rootId = requireId(root); @@ -886,16 +1121,16 @@ function readCodeDecls( if (message !== undefined) { throw new UnsupportedEventFeatureError( rootId, - 'a declared error message needs a code to be keyed by, but this ' + - 'error root has no errorCode', + "this error root declares an error message but no 'errorCode'", + 'note: a declared error message needs a code to be keyed by', ); } warnings.push({ elementId: rootId, category: 'unreferencedRoot', message: - `The ${label} root '${rootId}' has no code, so it cannot be keyed ` + - 'or represented in the model; it was not imported.', + `${label} root '${rootId}' has no code and was not imported\n` + + 'note: without a code it cannot be keyed or represented in the script', }); continue; } @@ -909,7 +1144,7 @@ function readCodeDecls( throw new UnsupportedEventFeatureError( rootId, `error roots '${prior.rootId}' and '${rootId}' both declare code ` + - `"${code}" but disagree about the thrown message`, + `'${code}' but disagree about the thrown message`, ); } continue; @@ -972,8 +1207,8 @@ function warnUnreferencedNamedRoot( elementId: rootId, category: 'extensionAttribute', message: - `The '${dataRefProperty}' setting on ${label} root '${rootId}' names a ` + - 'data structure Operaton does not execute; it was not imported.', + `setting '${dataRefProperty}' on ${label} root '${rootId}' was not imported\n` + + 'note: it names a data structure Operaton does not execute', }); } return; @@ -984,10 +1219,10 @@ function warnUnreferencedNamedRoot( category: 'unreferencedRoot', message: name !== undefined - ? `The ${label} "${name}" declared by root '${rootId}' is never used ` + - 'by an on/throw/emit; it was not imported.' - : `The ${label} root '${rootId}' has no name, so it cannot be keyed ` + - 'or represented in the model; it was not imported.', + ? `${label} '${name}' declared by root '${rootId}' was not imported\n` + + "note: no 'on', 'throw' or 'emit' uses it" + : `${label} root '${rootId}' has no name and was not imported\n` + + 'note: without a name it cannot be keyed or represented in the script', }); } @@ -1039,6 +1274,7 @@ function positionsUnderRoots( interface DroppedCondition { xsiType: string; body: string; + documentation: DocumentationText[]; position: string; language?: string; resource?: string; @@ -1107,12 +1343,13 @@ function nameAnonymousElements( `${el.$type.slice('bpmn:'.length)}_${parent.id ?? ''}`, taken, ); + // The end-event branch claims in `endTaken` alone. taken.add(id); endTaken.add(id).add(endIdOf(id)); const parentId = authoredId(parent); minted.set( id, - `a ${xmlTagOf(el.$type)} without an id` + + `a '${xmlTagOf(el.$type)}' without an id` + (parentId === undefined ? '' : ` in '${parentId}'`), ); (el as { id?: string }).id = id; @@ -1135,6 +1372,8 @@ function scanUnprefixedConditions(xml: string): Map { const parser = new Parser(); let flowId: string | undefined; let open: (DroppedCondition & { flowId: string }) | undefined; + // Elements opened inside `open`; text belongs to the body only at depth 0. + let depth = 0; parser.on( 'openTag', (elementName, getAttrs, _decodeEntities, selfClosing, getContext) => { @@ -1143,6 +1382,17 @@ function scanUnprefixedConditions(xml: string): Map { flowId = selfClosing ? undefined : getAttrs()['id']; return; } + if (open !== undefined) { + if (depth === 0 && local === 'documentation') { + const textFormat = getAttrs()['textFormat']; + open.documentation.push({ + text: '', + textFormat: textFormat ?? PLAIN_TEXT, + }); + } + if (!selfClosing) depth++; + return; + } if (local !== 'conditionExpression' || flowId === undefined) return; const attrs = getAttrs(); const xsiType = attrs['xsi:type']; @@ -1152,6 +1402,7 @@ function scanUnprefixedConditions(xml: string): Map { flowId, xsiType, body: '', + documentation: [], position: positionKey(line, column), language: nonEmptyAttr(attrs, 'language'), resource: nonEmptyAttr(attrs, 'resource'), @@ -1162,15 +1413,22 @@ function scanUnprefixedConditions(xml: string): Map { } }, ); + const appendText = (text: string): void => { + if (open === undefined) return; + if (depth === 0) open.body += text; + else if (depth === 1 && open.documentation.length > 0) { + open.documentation.at(-1)!.text += text; + } + }; parser.on('text', (value, decodeEntities) => { - if (open !== undefined) open.body += decodeEntities(value); - }); - parser.on('cdata', (value) => { - if (open !== undefined) open.body += value; + appendText(decodeEntities(value)); }); + parser.on('cdata', appendText); parser.on('closeTag', (elementName) => { const local = localNameOf(elementName); - if (local === 'conditionExpression' && open !== undefined) { + if (open !== undefined && depth > 0) { + depth--; + } else if (local === 'conditionExpression' && open !== undefined) { found.set(open.flowId, open); open = undefined; } else if (local === 'sequenceFlow') { @@ -1204,19 +1462,18 @@ function nonEmptyAttr( * Attributed to the process: moddle cannot tie an unparsable element to a step. */ function collectUnparsableResidualDrops( - moddleWarnings: unknown, + moddleWarnings: readonly ModdleWarning[], xml: string, processId: string, warnings: ImportWarning[], reportedRootIds: ReadonlySet, recoveredPositions: ReadonlySet, ): void { - const list = (moddleWarnings as ModdleWarning[] | undefined) ?? []; const underReportedRoot = new Set([ ...positionsUnderRoots(xml, reportedRootIds), ...recoveredPositions, ]); - for (const warning of list) { + for (const warning of moddleWarnings) { const message = String(warning.message ?? ''); const match = /unparsable content <([^>]+)>/i.exec(message); if (match === null) continue; @@ -1235,8 +1492,8 @@ function collectUnparsableResidualDrops( elementId: processId, category: 'extensionAttribute', message: - `Extra engine-specific configuration (${construct}${location}) was not ` + - 'imported; it could not be attributed to a specific step.', + `engine-specific configuration (${construct}${location}) was not imported\n` + + 'note: it could not be attributed to a specific step', }); } } @@ -1249,8 +1506,8 @@ function mapProcess( const id = processEl.id; if (id === undefined) { throw new UnsupportedDocumentError( - 'its process has no id; Operaton deploys a process under its id as ' + - 'the definition key, and the deployment fails without one', + 'its process has no id', + 'note: Operaton deploys a process under its id as the definition key, and the deployment fails without one', ); } const named = readNamed(processEl, id, warnings); @@ -1263,19 +1520,18 @@ function mapProcess( elementId: id, category: 'behaviourChanged', message: - `The process '${id}' is marked isExecutable="false", which this ` + - 'surface cannot express: it was imported as an executable process ' + - 'and is written back as one, so an engine will deploy and run what ' + - 'the source document held back.', + `process '${id}' was imported as executable although it is marked 'isExecutable="false"'\n` + + 'note: BPMNscript cannot express a process that is not executable\n' + + 'note: it is written back as executable, so an engine deploys and runs what the original document held back', }); } else if (isExecutable !== true) { warnings.push({ elementId: id, category: 'behaviourChanged', message: - `The process '${id}' is not marked isExecutable="true", which ` + - '`BpmnParse.parseProcessDefinitions` skips in a new deployment; it ' + - 'was imported as an executable process and is written back as one.', + `process '${id}' was imported as executable although it is not marked 'isExecutable="true"'\n` + + "note: 'BpmnParse.parseProcessDefinitions' skips such a process in a new deployment\n" + + 'note: it is written back as executable', }); } @@ -1290,25 +1546,17 @@ function mapProcess( 'process', ); const versionTag = readNamespacedAttr(processEl, 'versionTag'); - // The exporter stamps this default on every process; reading it back would - // break the round trip. - const authoredTimeToLive = readNamespacedAttr(processEl, 'historyTimeToLive'); - if (authoredTimeToLive === undefined) { + const historyTimeToLive = readNamespacedAttr(processEl, 'historyTimeToLive'); + if (historyTimeToLive === undefined) { warnings.push({ elementId: id, category: 'behaviourChanged', message: - `The process '${id}' sets no historyTimeToLive, which ` + - 'HistoryTimeToLiveParser.parseAndValidate refuses under the ' + - "engine's default enforceHistoryTimeToLive; it is written back with " + - `'${HISTORY_TIME_TO_LIVE}', so the rebuilt process deploys where the ` + - 'source did not.', + `process '${id}' sets no 'historyTimeToLive' and is written back with '${HISTORY_TIME_TO_LIVE}'\n` + + "note: 'HistoryTimeToLiveParser.parseAndValidate' refuses a process without it under the engine's default 'enforceHistoryTimeToLive'\n" + + 'note: the rebuilt process deploys where the original did not', }); } - const historyTimeToLive = - authoredTimeToLive === HISTORY_TIME_TO_LIVE - ? undefined - : authoredTimeToLive; // `BpmnParse.isStartable` compares ignoring case, and anything else is false. const startable = processEl.get('operaton:isStartableInTasklist'); const isStartableInTasklist = @@ -1328,11 +1576,13 @@ function mapProcess( id, ...named, isExecutable: true, - ...(versionTag === undefined ? {} : { versionTag }), - ...(historyTimeToLive === undefined ? {} : { historyTimeToLive }), - ...(candidateStarterUsers === undefined ? {} : { candidateStarterUsers }), - ...(candidateStarterGroups === undefined ? {} : { candidateStarterGroups }), - ...(isStartableInTasklist === undefined ? {} : { isStartableInTasklist }), + ...defined({ + versionTag, + historyTimeToLive, + candidateStarterUsers, + candidateStarterGroups, + isStartableInTasklist, + }), flowElements, sequenceFlows, }; @@ -1355,14 +1605,13 @@ function readPotentialStarters( ): void => { warnings.push({ elementId: id, category, message }); }; - for (const starter of extensionValues(processEl)) { - if (starter.$type !== 'operaton:PotentialStarter') continue; + for (const starter of extensionsOf(processEl, 'operaton:PotentialStarter')) { const text = formalExpressionTextOf(starter); if (text === undefined) { report( - `The operaton:potentialStarter on '${id}' was not imported: it ` + - 'carries no formal expression, and Operaton reads nothing else off ' + - 'it (BpmnParse.parsePotentialStarterResourceAssignment).', + `'operaton:potentialStarter' on '${id}' was not imported\n` + + 'note: it carries no formal expression, and Operaton reads nothing ' + + "else off it ('BpmnParse.parsePotentialStarterResourceAssignment')", ); continue; } @@ -1374,12 +1623,12 @@ function readPotentialStarters( ['candidateStarterGroups', split.groups], ].filter((entry): entry is [string, string[]] => entry[1].length > 0); report( - `The operaton:potentialStarter on '${id}' imports as ` + - `${became.map(([key, value]) => `${key}: "${value.join(',')}"`).join(' and ')}: ` + - 'Operaton reads its formal expression that way ' + - '(BpmnParse.parsePotentialStarterResourceAssignment), and this tool ' + - `writes it back as ${became.map(([key]) => `operaton:${key}`).join(' and ')}, ` + - 'which the engine reads the same.', + `'operaton:potentialStarter' on '${id}' imports as ` + + `${became.map(([key, value]) => `'${key}: "${value.join(',')}"'`).join(' and ')}\n` + + 'note: Operaton reads its formal expression that way ' + + "('BpmnParse.parsePotentialStarterResourceAssignment')\n" + + `note: BPMNscript writes it back as ${became.map(([key]) => `'operaton:${key}'`).join(' and ')}, ` + + 'which the engine reads the same', 'rewritten', ); } @@ -1414,8 +1663,9 @@ function refuseAnonymousEvents(container: ModdleElement): void { const failure = anonymousFailure(child, container); if (failure === undefined) continue; throw new UnsupportedDocumentError( - `a ${xmlTagOf(child.$type)} in '${container.id}' has no id${failure}; ` + - 'give it one', + `a '${xmlTagOf(child.$type)}' in '${container.id}' has no id`, + failure, + 'help: give it an id', ); } } @@ -1426,15 +1676,15 @@ function anonymousFailure( ): string | undefined { const defs = eventDefinitionsOf(el).map((def) => def.$type); if (defs.includes('bpmn:TimerEventDefinition')) { - return ', which BpmnParse.parseTimer fails the deployment on (\'Attribute "id" is required!\')'; + return `note: 'BpmnParse.parseTimer' fails the deployment on it ('Attribute "id" is required!')`; } switch (el.$type) { case 'bpmn:StartEvent': return container.$type === 'bpmn:Process' - ? ', which BpmnParse.parseStartFormHandlers fails the deployment on' - : ', which HistoryParseListener.parseStartEvent fails the deployment on'; + ? "note: 'BpmnParse.parseStartFormHandlers' fails the deployment on it" + : "note: 'HistoryParseListener.parseStartEvent' fails the deployment on it"; case 'bpmn:EventBasedGateway': - return ', which BpmnParse.parseEventBasedGateway fails the deployment on'; + return "note: 'BpmnParse.parseEventBasedGateway' fails the deployment on it"; case 'bpmn:BoundaryEvent': return defs.length === 1 ? ANONYMOUS_BOUNDARY_FAILURES.get(defs[0]) @@ -1447,33 +1697,33 @@ function anonymousFailure( const ANONYMOUS_BOUNDARY_FAILURES: ReadonlyMap = new Map([ [ 'bpmn:MessageEventDefinition', - ', which BpmnParse.parseBoundaryMessageEventDefinition fails the ' + - 'deployment on ("boundary event has no id")', + "note: 'BpmnParse.parseBoundaryMessageEventDefinition' fails the " + + 'deployment on it ("boundary event has no id")', ], [ 'bpmn:SignalEventDefinition', - ', which BpmnParse.parseBoundarySignalEventDefinition fails the ' + - 'deployment on ("boundary event has no id")', + "note: 'BpmnParse.parseBoundarySignalEventDefinition' fails the " + + 'deployment on it ("boundary event has no id")', ], [ 'bpmn:ErrorEventDefinition', - ': Operaton deploys it and ' + - 'ErrorDeclarationForProcessInstanceFinder.isReThrowingErrorEventSubprocess ' + + 'note: Operaton deploys it, and ' + + "'ErrorDeclarationForProcessInstanceFinder.isReThrowingErrorEventSubprocess' " + 'fails the run when an error is thrown inside its step', ], [ 'bpmn:EscalationEventDefinition', - ': Operaton deploys it and ExecutionEntity.generateActivityInstanceId ' + + "note: Operaton deploys it, and 'ExecutionEntity.generateActivityInstanceId' " + 'fails the run when an escalation reaches it', ], [ 'bpmn:CancelEventDefinition', - ': Operaton deploys it and ExecutionEntity.generateActivityInstanceId ' + + "note: Operaton deploys it, and 'ExecutionEntity.generateActivityInstanceId' " + 'fails the run when the transaction is cancelled', ], [ 'bpmn:ConditionalEventDefinition', - ': Operaton deploys it and ConditionalEventHandler.handleEvent fails ' + + "note: Operaton deploys it, and 'ConditionalEventHandler.handleEvent' fails " + 'the run of its step', ], ]); @@ -1493,9 +1743,10 @@ function checkStartEventCount( if (starts.length === 0 && hostKind !== 'transaction') { throw new UnsupportedEventFeatureError( id, - 'it has no start event, which BpmnParse.parseStartEvents fails the ' + - `deployment on ("${hostKind} must define a startEvent element")`, - 'Add a start event and lead it to the first step.', + 'it has no start event', + "note: 'BpmnParse.parseStartEvents' fails the deployment on it " + + `("${hostKind} must define a startEvent element")`, + 'help: add a start event and lead it to the first step', ); } if (hostKind === 'process') { @@ -1507,12 +1758,11 @@ function checkStartEventCount( if (initialCandidates.length > 1) { throw new UnsupportedEventFeatureError( requireId(initialCandidates[1]), - `the process '${id}' has ${initialCandidates.length} plain or timer ` + - 'starts, which BpmnParse.selectInitial fails the deployment on ' + + `the process '${id}' has ${initialCandidates.length} plain or timer starts`, + "note: 'BpmnParse.selectInitial' fails the deployment on it " + '("multiple none start events or timer start events not ' + 'supported on process definition")', - 'Leave one plain or timer start; the others may carry a message, ' + - 'signal, or condition trigger.', + 'help: leave one plain or timer start, and give the others a message, signal or condition trigger', ); } return; @@ -1520,9 +1770,9 @@ function checkStartEventCount( if (starts.length > 1) { throw new UnsupportedEventFeatureError( id, - `it has ${starts.length} start events; this tool writes one entry ` + - 'point per subprocess or transaction, so a second start has nowhere to go', - 'Leave one start and connect the steps that followed the others onto it.', + `it has ${starts.length} start events`, + 'note: BPMNscript writes one entry point per subprocess or transaction, so a second start has nowhere to go', + 'help: leave one start and connect the steps that followed the others onto it', ); } if (starts.length === 0) { @@ -1530,27 +1780,34 @@ function checkStartEventCount( elementId: id, category: 'behaviourChanged', message: - `The bpmn:transaction '${id}' has no start event: Operaton deploys ` + - 'it and SubProcessActivityBehavior.execute fails on entering it ' + - '("No initial activity found"); the script adds a start, so the ' + - 'imported block runs.', + `transaction '${id}' has no start event\n` + + "note: Operaton deploys it, and 'SubProcessActivityBehavior.execute' " + + 'fails on entering it ("No initial activity found")\n' + + 'note: the printed script adds a start, so the imported block runs', }); } } const ACTIVITY_TYPES: ReadonlySet = new Set(ACTIVITY_TAGS); -export const IS_FOR_COMPENSATION_DETAIL = - 'isForCompensation="true" marks this activity as excluded from normal ' + - 'flow: the boundary-event compensation-handler pattern, which this ' + - 'tool cannot import; wrap the steps in their own subprocess and ' + - 'target it with "on compensation" instead'; +const COMPENSATION_HELP = + "help: wrap the steps in their own subprocess and target it with 'on compensation'"; + +const IS_FOR_COMPENSATION_DETAIL = `'isForCompensation="true"' marks this activity as excluded from normal flow`; + +const IS_FOR_COMPENSATION_LINES = [ + "note: that is BPMN's boundary-event compensation-handler pattern, which BPMNscript cannot import", + COMPENSATION_HELP, +]; + +const COMPENSATION_BOUNDARY_DETAIL = + 'a compensation boundary event is not imported'; -export const COMPENSATION_BOUNDARY_DETAIL = - 'a compensation boundary event is not imported: BPMN attaches ' + - 'compensation through isForCompensation and a bpmn:association on ' + - 'the activity being compensated, not a boundary event; wrap the ' + - 'steps in their own subprocess and target it with "on compensation" instead'; +const COMPENSATION_BOUNDARY_LINES = [ + "note: BPMN attaches compensation through 'isForCompensation' and a " + + "'bpmn:association' on the activity being compensated, not a boundary event", + COMPENSATION_HELP, +]; function refuseIfForCompensation( child: ModdleElement, @@ -1561,7 +1818,10 @@ function refuseIfForCompensation( const id = child.id ?? '(unknown)'; throw new UnsupportedEventFeatureError( id, - describePairedHandler(containerEl, id, child) ?? IS_FOR_COMPENSATION_DETAIL, + ...(describePairedHandler(containerEl, id, child) ?? [ + IS_FOR_COMPENSATION_DETAIL, + ...IS_FOR_COMPENSATION_LINES, + ]), ); } @@ -1569,7 +1829,7 @@ function describePairedHandler( containerEl: ModdleElement, handlerId: string, handlerEl: ModdleElement, -): string | undefined { +): [detail: string, ...lines: string[]] | undefined { const boundaryEl = findAssociationEnd(containerEl, 'targetRef', handlerId); const boundary = boundaryEl === undefined ? undefined : compensationBoundaryOf(boundaryEl); @@ -1580,12 +1840,13 @@ function describePairedHandler( handlerEl, ); if (rewrite === undefined) return undefined; - return ( - `isForCompensation="true" marks '${handlerId}' as the handler a ` + - `bpmn:association targets from the boundary event '${boundary.id}' on ` + - `'${requireId(boundary.hostEl)}'; BPMN's boundary-plus-association ` + - `pattern is not imported. Write it by hand instead:\n\n${rewrite}` - ); + return [ + `'isForCompensation="true"' marks '${handlerId}' as the handler a ` + + `'bpmn:association' targets from boundary event '${boundary.id}' on ` + + `'${requireId(boundary.hostEl)}'`, + "note: BPMN's boundary-plus-association pattern is not imported", + ...rewrite, + ]; } function findAssociationEnd( @@ -1659,43 +1920,44 @@ function mapActivityStandalone( el: ModdleElement, warnings: ImportWarning[], ): FlowElement { - const out = [ + return withRepetition( el.$type === 'bpmn:SubProcess' ? mapSubProcess(el, warnings) : mapActivityByTag(el, warnings), - ]; - attachRepetition(out, 0, el, warnings); - return out[0]; + el, + warnings, + ); } function describePairedBoundary( containerEl: ModdleElement, boundaryId: string, hostEl: ModdleElement, -): string | undefined { +): [detail: string, ...lines: string[]] | undefined { const handlerEl = findAssociationEnd(containerEl, 'sourceRef', boundaryId); if (handlerEl === undefined) return undefined; // The engine rejects an association to a non-handler too. if (handlerEl.get('isForCompensation') !== true) return undefined; const rewrite = describeCompensationRewrite(hostEl, boundaryId, handlerEl); if (rewrite === undefined) return undefined; - return ( - `the boundary event '${boundaryId}' compensates '${requireId(hostEl)}' ` + - `through the handler '${requireId(handlerEl)}' its bpmn:association ` + - `targets; a compensation boundary event is not imported. Write it by ` + - `hand instead:\n\n${rewrite}` - ); + return [ + `boundary event '${boundaryId}' compensates '${requireId(hostEl)}' ` + + `through the handler '${requireId(handlerEl)}' its 'bpmn:association' targets`, + 'note: a compensation boundary event is not imported', + ...rewrite, + ]; } /** - * `undefined` when either activity refuses: the preview only decorates a - * refusal already in flight. + * A help line followed by the script, indented under it. `undefined` when + * either activity refuses: the preview only decorates a refusal already in + * flight. */ function describeCompensationRewrite( hostEl: ModdleElement, boundaryId: string, handlerEl: ModdleElement, -): string | undefined { +): string[] | undefined { const hostId = requireId(hostEl); let compensated: FlowElement; let handler: FlowElement; @@ -1720,7 +1982,13 @@ function describeCompensationRewrite( id: handlerId, triggeredByEvent: true, flowElements: [compensationStart, handler], - sequenceFlows: [], + sequenceFlows: [ + { + id: `Flow_${compensationStart.id}_${handler.id}`, + sourceRef: compensationStart.id, + targetRef: handler.id, + }, + ], }; const wrapper: SubProcess = { kind: 'subProcess', @@ -1737,7 +2005,10 @@ function describeCompensationRewrite( // Strip the `process Preview { ... }` shell and its indent. const lines = irToDsl(preview).source.split('\n').slice(1, -2); - return lines.map((line) => line.slice(INDENT.length)).join('\n'); + return [ + 'help: write it by hand instead', + ...lines.map((line) => ` ${line.slice(INDENT.length)}`), + ]; } /** @@ -1758,9 +2029,9 @@ function warnIgnoredQuantityAttrs( elementId: id, category: 'unmappedConstruct', message: - `The '${name}' attribute on '${id}' was not imported: Operaton's ` + - 'BpmnParse never reads it, so the step runs as if it read 1, which ' + - 'is what the source document runs.', + `attribute '${name}' on '${id}' was not imported\n` + + "note: Operaton's 'BpmnParse' never reads it, so the step runs as if " + + 'it read 1, as the original document does', }); } } @@ -1780,74 +2051,21 @@ function mapContainerChildren( const children = el.get('flowElements') as ModdleElement[]; for (const child of children) { refuseIfForCompensation(child, el); - const mappedAt = flowElements.length; - switch (child.$type) { - case 'bpmn:StartEvent': - flowElements.push(mapStart(child)); - break; - case 'bpmn:EndEvent': - flowElements.push(mapEndEvent(child, warnings, hostKind)); - break; - case 'bpmn:IntermediateThrowEvent': - flowElements.push(mapIntermediateThrowEvent(child, warnings)); - break; - case 'bpmn:IntermediateCatchEvent': - flowElements.push(mapIntermediateCatchEvent(child, warnings)); - break; - case 'bpmn:BoundaryEvent': - flowElements.push(mapBoundaryEvent(child, warnings, el)); - break; - case 'bpmn:ExclusiveGateway': - flowElements.push( - mapDefaultingGateway(child, 'exclusiveGateway', warnings), - ); - break; - case 'bpmn:InclusiveGateway': - flowElements.push( - mapDefaultingGateway(child, 'inclusiveGateway', warnings), - ); - break; - case 'bpmn:ParallelGateway': - flowElements.push(mapParallelGateway(child, warnings)); - break; - case 'bpmn:EventBasedGateway': - flowElements.push(mapEventBasedGateway(child, warnings)); - break; - case 'bpmn:SubProcess': - flowElements.push( - child.get('triggeredByEvent') === true - ? mapEventSubProcess(child, warnings, hostKind) - : mapSubProcess(child, warnings), - ); - break; - // Operaton ignores `triggeredByEvent` on a transaction, so it maps like a - // task. - case 'bpmn:Task': - case 'bpmn:ManualTask': - case 'bpmn:UserTask': - case 'bpmn:ServiceTask': - case 'bpmn:SendTask': - case 'bpmn:BusinessRuleTask': - case 'bpmn:ReceiveTask': - case 'bpmn:ScriptTask': - case 'bpmn:Transaction': - case 'bpmn:CallActivity': - flowElements.push(mapActivityByTag(child, warnings)); - break; - case 'bpmn:SequenceFlow': - sequenceFlows.push(mapSequenceFlow(child, warnings)); - break; - // `continue` skips the sweeps below, which would warn again for - // `bpmn:dataState`. - case 'bpmn:DataObject': - case 'bpmn:DataObjectReference': - case 'bpmn:DataStoreReference': - warnDataConstructDrop(child, el.id, warnings); - continue; - default: - throw new UnsupportedElementError(child.$type, authoredId(child)); + if (child.$type === 'bpmn:SequenceFlow') { + sequenceFlows.push(mapSequenceFlow(child, warnings)); + } else if (DATA_CONSTRUCT_TAGS.has(child.$type)) { + // Skips the sweeps below, which would warn again for `bpmn:dataState`. + warnDataConstructDrop(child, el.id, warnings); + continue; + } else { + flowElements.push( + withRepetition( + mapFlowNode(child, el, warnings, mapStart, hostKind), + child, + warnings, + ), + ); } - attachRepetition(flowElements, mappedAt, child, warnings); if (child.id !== undefined) { warnIgnoredQuantityAttrs(child, child.id, warnings); // `parseStartFormHandlers` runs for the process's own start alone. @@ -1886,6 +2104,49 @@ function mapContainerChildren( return { flowElements, sequenceFlows }; } +const DATA_CONSTRUCT_TAGS: ReadonlySet = new Set([ + 'bpmn:DataObject', + 'bpmn:DataObjectReference', + 'bpmn:DataStoreReference', +]); + +function mapFlowNode( + child: ModdleElement, + containerEl: ModdleElement, + warnings: ImportWarning[], + mapStart: (startEl: ModdleElement) => StartEvent, + hostKind: ContainerHostKind, +): FlowElement { + switch (child.$type) { + case 'bpmn:StartEvent': + return mapStart(child); + case 'bpmn:EndEvent': + return mapEndEvent(child, warnings, hostKind); + case 'bpmn:IntermediateThrowEvent': + return mapIntermediateThrowEvent(child, warnings); + case 'bpmn:IntermediateCatchEvent': + return mapIntermediateCatchEvent(child, warnings); + case 'bpmn:BoundaryEvent': + return mapBoundaryEvent(child, warnings, containerEl); + case 'bpmn:ExclusiveGateway': + return mapGateway(child, 'exclusiveGateway', warnings); + case 'bpmn:InclusiveGateway': + return mapGateway(child, 'inclusiveGateway', warnings); + case 'bpmn:ParallelGateway': + return mapGateway(child, 'parallelGateway', warnings); + case 'bpmn:EventBasedGateway': + return mapEventBasedGateway(child, warnings); + case 'bpmn:SubProcess': + return child.get('triggeredByEvent') === true + ? mapEventSubProcess(child, warnings, hostKind) + : mapSubProcess(child, warnings); + // Operaton ignores `triggeredByEvent` on a transaction, so it maps like a + // task. + default: + return mapActivityByTag(child, warnings); + } +} + /** * The engine throws `missingDefaultFlowException` on a `default` that does not * leave the step; a dangling one is read back from moddle. @@ -1904,27 +2165,53 @@ function attachDefaultFlows( const index = flowElements.findIndex((fe) => fe.id === child.id); const mapped = flowElements[index]; if (mapped === undefined || !isActivity(mapped)) continue; - if ( - sequenceFlows.some( - (sf) => sf.id === defaultFlowId && sf.sourceRef === mapped.id, - ) - ) { + const routes = sequenceFlows.some( + (sf) => sf.id === defaultFlowId && sf.sourceRef === mapped.id, + ); + if (mapped.loop !== undefined) { + warnings.push(repeatedDefaultWarning(mapped.id, defaultFlowId, routes)); + } + if (routes) { flowElements[index] = { ...mapped, defaultFlowId }; continue; } + if (mapped.loop !== undefined) continue; warnings.push({ elementId: mapped.id, category: 'unmappedConstruct', message: - `The 'default' attribute on '${mapped.id}' was not imported: it ` + - `names '${defaultFlowId}', which is not a route out of the step, so ` + - 'BpmnActivityBehavior.handleNoTransitions finds no flow to take and ' + - 'fails the step whenever no other route holds; the imported step ' + - 'names no fallback.', + `attribute 'default' on '${mapped.id}' was not imported\n` + + `note: it names '${defaultFlowId}', which is not a route out of the step\n` + + "note: 'BpmnActivityBehavior.handleNoTransitions' finds no flow to " + + 'take there and fails the step whenever no other route holds\n' + + 'note: the imported step names no fallback', }); } } +/** + * `BpmnParse.parseSequenceFlow` moves a repeated step's routes onto its + * multi-instance body, while `createActivityOnScope` leaves `default` on the + * step inside it. + */ +function repeatedDefaultWarning( + id: string, + defaultFlowId: string, + routes: boolean, +): ImportWarning { + return { + elementId: id, + category: 'behaviourChanged', + message: + `repeated step '${id}' names default flow '${defaultFlowId}' and can never complete\n` + + 'note: the engine hangs the routes out of a repeated step on the repetition around it, so a single run has no route to take as its default\n' + + "note: every run fails on completion with 'missingDefaultFlowException', whatever the conditions\n" + + (routes + ? `note: the rebuilt process takes '${defaultFlowId}' as the fallback of a split after the step, which completes` + : 'note: the imported step names no fallback and completes'), + }; +} + /** * Asked of the document: the engine counts an empty condition and a dangling * `default`, which the IR drops. @@ -1999,15 +2286,14 @@ function checkHandlerFlows( throw new UnsupportedEventFeatureError( handlerId, entering - ? `the flow '${sf.id}' enters the event handler '${handlerId}', ` + - 'which BpmnParse.parseSequenceFlow fails the deployment on ' + - '("Invalid incoming sequence flow of event subprocess"); a ' + - 'handler is entered by its trigger' - : `the flow '${sf.id}' leaves the event handler '${handlerId}', ` + - 'which BpmnParse.parseSequenceFlow fails the deployment on ' + - '("Invalid outgoing sequence flow of event subprocess"); a ' + - 'handler ends where its body ends', - `Take the flow '${sf.id}' off; the handler runs when its trigger fires.`, + ? `the flow '${sf.id}' enters the event handler '${handlerId}'` + : `the flow '${sf.id}' leaves the event handler '${handlerId}'`, + "note: 'BpmnParse.parseSequenceFlow' fails the deployment on it " + + `("Invalid ${entering ? 'incoming' : 'outgoing'} sequence flow of event subprocess")`, + entering + ? 'note: a handler is entered by its trigger' + : 'note: a handler ends where its body ends', + `help: take the flow '${sf.id}' off, since the handler runs when its trigger fires`, ); } } @@ -2032,73 +2318,57 @@ function checkLinkFlows( const catches = linkEnds('intermediateCatchEvent'); if (throws.size === 0 && catches.size === 0) return; - const refuse = (id: string, detail: string, flowId: string): never => { + const refuse = ( + id: string, + detail: string, + note: string, + flowId: string, + ): never => { throw new UnsupportedEventFeatureError( id, detail, - `Take the flow '${flowId}' off, and lead it from or to a step instead.`, + note, + `help: take the flow '${flowId}' off, and lead it from or to a step instead`, ); }; for (const sf of sequenceFlows) { if (throws.has(sf.sourceRef)) { refuse( sf.sourceRef, - `the flow '${sf.id}' leaves the link throw '${sf.sourceRef}'; a ` + - 'link throw ends its path, and the token continues at the catch ' + - 'of the same name rather than along a flow', + `the flow '${sf.id}' leaves the link throw '${sf.sourceRef}'`, + 'note: a link throw ends its path, and the token continues at the ' + + 'catch of the same name rather than along a flow', sf.id, ); } if (catches.has(sf.targetRef)) { refuse( sf.targetRef, - `the flow '${sf.id}' enters the link catch '${sf.targetRef}'; a ` + - 'link catch is entered by the throw of the same name rather than ' + - 'along a flow', + `the flow '${sf.id}' enters the link catch '${sf.targetRef}'`, + 'note: a link catch is entered by the throw of the same name rather ' + + 'than along a flow', sf.id, ); } } } -const ACTIVITY_KINDS = [ - 'task', - 'userTask', - 'serviceTask', - 'receiveTask', - 'scriptTask', - 'subProcess', - 'callActivity', -] as const; - -type ActivityElement = Extract< - FlowElement, - { kind: (typeof ACTIVITY_KINDS)[number] } ->; - -function isActivity(node: FlowElement): node is ActivityElement { - return (ACTIVITY_KINDS as readonly string[]).includes(node.kind); -} - /** * Operaton wraps whatever the tag dispatch produces, so one reader serves every * tag. */ -function attachRepetition( - flowElements: FlowElement[], - index: number, +function withRepetition( + mapped: FlowElement, child: ModdleElement, warnings: ImportWarning[], -): void { - // A sequence flow pushed nothing at `index`. - const mapped = flowElements[index]; - if (mapped === undefined || !isActivity(mapped)) return; +): FlowElement { + if (!isActivity(mapped)) return mapped; const loop = readLoopCharacteristics(child, mapped.id, warnings); - if (loop === undefined) return; - flowElements[index] = { ...mapped, loop }; + return loop === undefined ? mapped : { ...mapped, loop }; } -const BOUNDARY_HOST_NOUNS = { +/** Every activity, and only an activity, hosts a boundary event. */ +const ACTIVITY_NOUNS = { task: 'plain task', userTask: 'user task', serviceTask: 'service task', @@ -2106,13 +2376,13 @@ const BOUNDARY_HOST_NOUNS = { scriptTask: 'script task', subProcess: 'subprocess', callActivity: 'call activity', -} as const satisfies Record<(typeof ACTIVITY_KINDS)[number], string>; +} as const satisfies Partial>; -type BoundaryHostKind = keyof typeof BOUNDARY_HOST_NOUNS; -type BoundaryHost = Extract; +type ActivityKind = keyof typeof ACTIVITY_NOUNS; +type ActivityElement = Extract; -const isBoundaryHost = (el: FlowElement): el is BoundaryHost => - Object.hasOwn(BOUNDARY_HOST_NOUNS, el.kind); +const isActivity = (el: FlowElement): el is ActivityElement => + Object.hasOwn(ACTIVITY_NOUNS, el.kind); /** One IR kind covers three tags. */ const SERVICE_TASK_LIKE_NOUNS = { @@ -2125,14 +2395,14 @@ const SUB_PROCESS_NOUNS = { transaction: 'attempt block', } as const; -function boundaryHostNoun(host: BoundaryHost): string { +function activityNoun(host: ActivityElement): string { if (host.kind === 'serviceTask' && host.element !== undefined) { return SERVICE_TASK_LIKE_NOUNS[host.element]; } if (host.kind === 'subProcess' && host.element !== undefined) { return SUB_PROCESS_NOUNS[host.element]; } - return BOUNDARY_HOST_NOUNS[host.kind]; + return ACTIVITY_NOUNS[host.kind]; } /** The `satisfies` makes a new kind stop the build here. */ @@ -2146,19 +2416,16 @@ const OTHER_FLOW_ELEMENT_NOUNS = { parallelGateway: 'split', inclusiveGateway: 'split', eventBasedGateway: 'wait with several branches', -} as const satisfies Record< - Exclude, - string ->; +} as const satisfies Record, string>; const flowElementNoun = (el: FlowElement): string => - isBoundaryHost(el) ? boundaryHostNoun(el) : OTHER_FLOW_ELEMENT_NOUNS[el.kind]; + isActivity(el) ? activityNoun(el) : OTHER_FLOW_ELEMENT_NOUNS[el.kind]; /** `u` opens only `user task`. */ const withArticle = (noun: string): string => `${/^[aeio]/i.test(noun) ? 'an' : 'a'} ${noun}`; -const BOUNDARY_HOST_NOUN_LIST = Object.entries(BOUNDARY_HOST_NOUNS).flatMap( +const ACTIVITY_NOUN_LIST = Object.entries(ACTIVITY_NOUNS).flatMap( ([kind, noun]) => { if (kind === 'serviceTask') { return [noun, ...Object.values(SERVICE_TASK_LIKE_NOUNS)]; @@ -2173,8 +2440,8 @@ const BOUNDARY_HOST_NOUN_LIST = Object.entries(BOUNDARY_HOST_NOUNS).flatMap( /** * The hosts `BpmnParse.parseBoundaryEvents` allows an escalation boundary on. */ -const ESCALATION_BOUNDARY_HOST_KINDS: ReadonlySet = - new Set(['subProcess', 'callActivity', 'userTask']); +const ESCALATION_BOUNDARY_HOST_KINDS: ReadonlySet = + new Set(['subProcess', 'callActivity', 'userTask']); /** The only cancel host. */ const givesUpItsWork = (el: FlowElement): el is SubProcess => @@ -2189,12 +2456,12 @@ function checkBoundaryEventHosts( sequenceFlows: SequenceFlow[], warnings: ImportWarning[], ): void { - const activityById = new Map(); + const activityById = new Map(); for (const el of flowElements) { // An event subprocess prints with no id, so a boundary on one has nothing // to print against. if ( - isBoundaryHost(el) && + isActivity(el) && !(el.kind === 'subProcess' && el.triggeredByEvent === true) ) { activityById.set(el.id, el); @@ -2206,11 +2473,7 @@ function checkBoundaryEventHosts( ); for (const sf of sequenceFlows) { if (!boundaryIds.has(sf.targetRef)) continue; - throw new UnsupportedEventFeatureError( - sf.targetRef, - 'a boundary event carries an incoming sequence flow; it is ' + - 'triggered by its own event, not by an incoming flow', - ); + throw incomingBoundaryFlowRefusal(sf.targetRef); } for (const el of flowElements) { @@ -2220,10 +2483,10 @@ function checkBoundaryEventHosts( if (host === undefined) { throw new UnsupportedEventFeatureError( el.id, - `attachedToRef "${el.attachedToRef}" does not name ` + - `${withArticle(formatPlainWordList(BOUNDARY_HOST_NOUN_LIST))} that is ` + - 'itself a flow element of this same container; a boundary event can ' + - 'only attach to an activity alongside it', + `'attachedToRef="${el.attachedToRef}"' does not name ` + + `${withArticle(formatPlainWordList(ACTIVITY_NOUN_LIST))} that is ` + + 'itself a flow element of this same container', + 'note: a boundary event can only attach to an activity alongside it', ); } if ( @@ -2232,23 +2495,24 @@ function checkBoundaryEventHosts( ) { throw new UnsupportedEventFeatureError( el.id, - `an escalation boundary event attaches to "${el.attachedToRef}", ` + - `${withArticle(boundaryHostNoun(host))}; Operaton only allows an ` + - `escalation boundary on ${formatPlainWordList( + `an escalation boundary event attaches to '${el.attachedToRef}', ` + + `${withArticle(activityNoun(host))}`, + 'note: Operaton only allows an escalation boundary on ' + + formatPlainWordList( [...ESCALATION_BOUNDARY_HOST_KINDS].map((kind) => - withArticle(BOUNDARY_HOST_NOUNS[kind]), + withArticle(ACTIVITY_NOUNS[kind]), ), - )}`, + ), ); } if (el.eventDefinition.kind === 'cancel' && !givesUpItsWork(host)) { throw new UnsupportedEventFeatureError( el.id, - `a cancel boundary event attaches to "${el.attachedToRef}", ` + - `${withArticle(boundaryHostNoun(host))}; 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 event attaches to '${el.attachedToRef}', ` + + `${withArticle(activityNoun(host))}`, + "note: Operaton only allows a cancel boundary on a 'bpmn:transaction', " + + 'and refuses to deploy the file otherwise', + "help: attach it to a 'bpmn:transaction', or take the cancel definition off it", ); } } @@ -2271,10 +2535,10 @@ function checkCancelPairing( if (boundaryByHost.has(el.attachedToRef)) { throw new UnsupportedEventFeatureError( el.id, - `a second cancel boundary event attaches to "${el.attachedToRef}"; ` + - 'Operaton allows one cancel boundary per block and refuses to ' + + `a second cancel boundary event attaches to '${el.attachedToRef}'`, + 'note: Operaton allows one cancel boundary per block and refuses to ' + 'deploy a file with two', - 'Leave one cancel boundary event on the block.', + 'help: leave one cancel boundary event on the block', ); } boundaryByHost.set(el.attachedToRef, el.id); @@ -2292,11 +2556,10 @@ function checkCancelPairing( elementId: el.id, category: 'carriedAsWritten', message: - `The block '${el.id}' holds an end event that gives it up, with ` + - 'no cancel boundary event attached to it: Operaton deploys the ' + - 'file and then stops with an error the first time that end is ' + - `reached. Write 'on ${el.id}: cancel { ... }' beside the block to ` + - 'catch it.', + `block '${el.id}' has an end event that gives it up, but no cancel boundary event\n` + + 'note: Operaton deploys the file, then stops with an error the ' + + 'first time that end is reached\n' + + `help: write 'on ${el.id}: cancel { ... }' beside the block to catch it`, }); } if (!givenUp && boundaryId !== undefined) { @@ -2304,9 +2567,9 @@ function checkCancelPairing( elementId: boundaryId, category: 'carriedAsWritten', message: - `The cancel boundary event on '${el.id}' was imported, but ` + - 'nothing inside the block gives it up, so what follows the ' + - 'boundary can never run.', + `cancel boundary event on '${el.id}' can never fire\n` + + 'note: it was imported, but nothing inside the block gives it up\n' + + 'note: what follows the boundary can never run', }); } } @@ -2345,12 +2608,12 @@ function checkWaitBranches( throw new UnsupportedEventFeatureError( sf.targetRef, `a branch of the wait '${sf.sourceRef}' leads to ` + - `'${sf.targetRef}', ${named}; 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', - 'Begin each branch with a message, timer, signal, or condition to ' + - 'wait for, and put the steps that follow it inside that branch.', + `'${sf.targetRef}', ${named}`, + 'note: every branch of a wait with several branches has to begin ' + + 'with something to wait for', + 'note: only a message, a timer, a signal or a condition counts as one here', + 'help: begin each branch with a message, timer, signal or condition ' + + 'to wait for, and put the steps that follow it inside that branch', ); } @@ -2359,11 +2622,11 @@ function checkWaitBranches( if ((incoming.get(target.id) ?? 0) > 1) { throw new UnsupportedEventFeatureError( target.id, - `'${target.id}' 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', - `Leave '${target.id}' with the one path into it that starts at ` + - `'${sf.sourceRef}', and lead the rest elsewhere.`, + `'${target.id}' is reached by more than one path`, + 'note: a step inside a wait with several branches can only be ' + + 'reached through the wait that opens it', + `help: leave '${target.id}' with the one path into it that starts at ` + + `'${sf.sourceRef}', and lead the rest elsewhere`, ); } } @@ -2388,7 +2651,7 @@ function mapSubProcess( kind: 'subProcess', id, ...named, - ...(element === undefined ? {} : { element }), + ...defined({ element }), ...readEngineAttributes(el, id, warnings), ...readIoMapping(el, id, warnings), flowElements, @@ -2398,11 +2661,11 @@ function mapSubProcess( /** `parseTransaction` ignores these. */ const IGNORED_TRANSACTION_ATTRS: ReadonlyMap = new Map([ - ['method', 'Operaton reads it on a not at all'], - ['protocol', 'Operaton reads it on a not at all'], + ['method', "Operaton does not read it on a 'bpmn:transaction'"], + ['protocol', "Operaton does not read it on a 'bpmn:transaction'"], [ 'triggeredByEvent', - 'Operaton ignores it on a and runs the block as an ' + + "Operaton ignores it on a 'bpmn:transaction' and runs the block as an " + 'ordinary step of the surrounding flow', ], ]); @@ -2421,8 +2684,9 @@ function warnIgnoredTransactionAttrs( elementId: id, category: 'unmappedConstruct', message: - `The '${name}' attribute on '${id}' was not imported: ${reason}, so ` + - 'the imported block runs exactly as the source document does.', + `attribute '${name}' on '${id}' was not imported\n` + + `note: ${reason}\n` + + 'note: the imported block runs exactly as the original document does', }); } } @@ -2446,6 +2710,7 @@ function mapEventSubProcess( id, 'an event handler must have exactly one start event carrying its ' + `trigger (found ${startEvents.length})`, + HANDLER_TRIGGER_NOTE, ); } @@ -2487,10 +2752,10 @@ function dropContinuationCopyOfTimerJobSettings( elementId: id, category: 'extensionAttribute', message: - `The '${key}' setting on '${id}' was not imported: the 'on timer' ` + - `head's ${key} configures the timer job its start event creates, ` + - "and the event sub-process's own copy, which only its async " + - 'continuation job takes, has no spelling in the script.', + `setting '${key}' on '${id}' was not imported\n` + + `note: the 'on timer' head's '${key}' configures the timer job its start event creates\n` + + "note: the event sub-process's own copy, which only its async " + + 'continuation job takes, has no spelling in the script', }); } return continuation; @@ -2510,6 +2775,7 @@ function mapEventSubProcessStart( handlerId, 'its start event must carry exactly one trigger definition ' + `(found ${defs.length})`, + HANDLER_TRIGGER_NOTE, ); } @@ -2529,10 +2795,11 @@ function mapEventSubProcessStart( ) { throw new UnsupportedEventFeatureError( handlerId, - 'a compensation handler must be hosted directly by the block whose ' + - `completed work it undoes, not by ${ - hostKind === 'process' ? 'the process' : 'another event subprocess' - }; move it inside that block`, + 'a compensation handler is hosted by ' + + (hostKind === 'process' ? 'the process' : 'another event subprocess'), + 'note: a compensation handler must be hosted directly by the block ' + + 'whose completed work it undoes', + 'help: move it inside that block', ); } @@ -2543,20 +2810,17 @@ function mapEventSubProcessStart( (eventDefinition.kind === 'error' || eventDefinition.kind === 'compensation') ) { + const trigger = withArticle(eventDefinition.kind); throw new UnsupportedEventFeatureError( handlerId, - eventDefinition.kind === 'error' - ? 'an error handler cannot be non-interrupting (isInterrupting="false")' + - '; BPMN requires an error trigger to interrupt its scope' - : 'a compensation handler cannot be non-interrupting ' + - '(isInterrupting="false"); BPMN requires a compensation trigger ' + - 'to interrupt its scope', + `${trigger} handler cannot be non-interrupting ('isInterrupting="false"')`, + `note: BPMN requires ${trigger} trigger to interrupt its scope`, ); } // Unlike a boundary, the start under `on` has a label slot; // warnElidedNamedDrop reports it if unprinted. - const named = readNamed(startEl, id, warnings); + const named = readNamed(startEl, id, warnings, readText(startEl, 'name')); return { kind: 'startEvent', id, @@ -2569,19 +2833,28 @@ function mapEventSubProcessStart( } const NON_INTERRUPTING_REFUSALS: Partial< - Record + Record > = { - error: - 'an error boundary event cannot be non-interrupting ' + - '(cancelActivity="false"); BPMN gives an error boundary no ' + - 'non-interrupting form', - cancel: - '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', + error: [ + `an error boundary event cannot be non-interrupting ('cancelActivity="false"')`, + 'note: BPMN gives an error boundary no non-interrupting form', + ], + cancel: [ + `a cancel boundary event cannot be non-interrupting ('cancelActivity="false"')`, + 'note: Operaton deploys it and lets what follows the boundary run ' + + 'beside the block instead of taking over from it, which BPMNscript ' + + 'cannot write back', + ], }; +function incomingBoundaryFlowRefusal(id: string): UnsupportedEventFeatureError { + return new UnsupportedEventFeatureError( + id, + 'a boundary event carries an incoming sequence flow', + 'note: it is triggered by its own event, not by an incoming flow', + ); +} + /** Validated later by {@link checkBoundaryEventHosts}. */ function mapBoundaryEvent( el: ModdleElement, @@ -2594,19 +2867,13 @@ function mapBoundaryEvent( if (hostEl === undefined) { throw new UnsupportedEventFeatureError( id, - 'a boundary event has no attachedToRef; BPMN requires every ' + - 'boundary event to attach to an activity in its own container', + "a boundary event has no 'attachedToRef'", + 'note: BPMN requires every boundary event to attach to an activity in its own container', ); } const incoming = el.get('incoming') as ModdleElement[]; - if (incoming.length > 0) { - throw new UnsupportedEventFeatureError( - id, - 'a boundary event carries an incoming sequence flow; it is ' + - 'triggered by its own event, not by an incoming flow', - ); - } + if (incoming.length > 0) throw incomingBoundaryFlowRefusal(id); refuseIoMapping(el, id, 'ensureNoIoMappingDefined'); @@ -2622,8 +2889,10 @@ function mapBoundaryEvent( if (defEl.$type === 'bpmn:CompensateEventDefinition') { throw new UnsupportedEventFeatureError( id, - describePairedBoundary(containerEl, id, hostEl) ?? + ...(describePairedBoundary(containerEl, id, hostEl) ?? [ COMPENSATION_BOUNDARY_DETAIL, + ...COMPENSATION_BOUNDARY_LINES, + ]), ); } @@ -2638,7 +2907,7 @@ function mapBoundaryEvent( const cancelActivity = el.get('cancelActivity') === false ? false : undefined; const nonInterrupting = NON_INTERRUPTING_REFUSALS[eventDefinition.kind]; if (cancelActivity === false && nonInterrupting !== undefined) { - throw new UnsupportedEventFeatureError(id, nonInterrupting); + throw new UnsupportedEventFeatureError(id, ...nonInterrupting); } return { @@ -2661,9 +2930,7 @@ function refuseIoMapping( id: string, method: 'ensureNoIoMappingDefined' | 'checkActivityInputOutputSupported', ): void { - if (!extensionValues(el).some((v) => v.$type === 'operaton:InputOutput')) { - return; - } + if (extensionsOf(el, 'operaton:InputOutput').length === 0) return; const tag = xmlTagOf(el.$type); const triggered = el.get('triggeredByEvent') === true @@ -2671,8 +2938,8 @@ function refuseIoMapping( : ''; throw new UnsupportedExtensionFormError( id, - `an operaton:inputOutput mapping on a <${tag}>, which BpmnParse.${method} ` + - 'fails the deployment on ("operaton:inputOutput mapping unsupported ' + + `an 'operaton:inputOutput' mapping on a '${tag}'`, + `note: 'BpmnParse.${method}' fails the deployment on it ("operaton:inputOutput mapping unsupported ` + `for element type '${localNameOf(tag)}'${triggered}")`, ); } @@ -2687,9 +2954,7 @@ function readCatchEventDefinition( warnings: ImportWarning[], position: 'start' | 'boundary' | 'intermediate catch', ): EventDefinition { - collectExtensionDrops(defEl, ownerId, warnings); - collectUnmappedBpmnDrops(defEl, ownerId, warnings); - warnDocumentationDrop(defEl, ownerId, 'an event definition', warnings); + sweepEventDefinition(defEl, ownerId, warnings); warnCatchSideImplementationAttrs(defEl, ownerId, warnings); if (defEl.$type === 'bpmn:ErrorEventDefinition') { @@ -2697,13 +2962,21 @@ function readCatchEventDefinition( const errorCode = ref ? readString(ref, 'errorCode') : readDanglingErrorCode(defEl, ownerId, warnings); - const codeVariable = readNamespacedAttr(defEl, 'errorCodeVariable'); - const messageVariable = readNamespacedAttr(defEl, 'errorMessageVariable'); + const codeVariable = readCatchVariable( + defEl, + ownerId, + 'errorCodeVariable', + 'code', + ); + const messageVariable = readCatchVariable( + defEl, + ownerId, + 'errorMessageVariable', + 'message', + ); return { kind: 'error', - ...(errorCode === undefined ? {} : { errorCode }), - ...(codeVariable === undefined ? {} : { codeVariable }), - ...(messageVariable === undefined ? {} : { messageVariable }), + ...defined({ errorCode, codeVariable, messageVariable }), }; } @@ -2713,18 +2986,22 @@ function readCatchEventDefinition( if (ref === undefined && written !== undefined) { throw new UnsupportedEventFeatureError( ownerId, - danglingEscalationDetail( + ...danglingEscalationDetail( written, 'createEscalationEventDefinitionForEscalationHandler', ), ); } const escalationCode = ref ? readString(ref, 'escalationCode') : undefined; - const codeVariable = readNamespacedAttr(defEl, 'escalationCodeVariable'); + const codeVariable = readCatchVariable( + defEl, + ownerId, + 'escalationCodeVariable', + 'code', + ); return { kind: 'escalation', - ...(escalationCode === undefined ? {} : { escalationCode }), - ...(codeVariable === undefined ? {} : { codeVariable }), + ...defined({ escalationCode, codeVariable }), }; } @@ -2732,13 +3009,16 @@ function readCatchEventDefinition( if (shared !== undefined) return shared; if (defEl.$type === 'bpmn:TimerEventDefinition') { - return { kind: 'timer', ...readTimerDefinition(defEl, ownerId) }; + return { + kind: 'timer', + ...readTimerDefinition(defEl, ownerId, warnings), + }; } if (defEl.$type === 'bpmn:ConditionalEventDefinition') { return { kind: 'conditional', - condition: readConditionalDefinition(defEl, ownerId, warnings), + ...readConditionalDefinition(defEl, ownerId, warnings), }; } @@ -2765,6 +3045,17 @@ function readCatchEventDefinition( ); } +function sweepEventDefinition( + defEl: ModdleElement, + ownerId: string, + warnings: ImportWarning[], + qualifier?: string, +): void { + collectExtensionDrops(defEl, ownerId, warnings, qualifier); + collectUnmappedBpmnDrops(defEl, ownerId, warnings); + warnDocumentationDrop(defEl, ownerId, 'an event definition', warnings); +} + /** The engine takes a dangling `errorRef`'s text as the code. */ function readDanglingErrorCode( defEl: ModdleElement, @@ -2777,13 +3068,13 @@ function readDanglingErrorCode( elementId: ownerId, category: 'rewritten', message: - `The errorRef '${written}' on '${ownerId}' names no bpmn:error root ` + - `and imports as the code '${written}': Operaton takes a dangling ` + - "reference's text as the code " + - '(BpmnParse.parseBoundaryErrorEventDefinition, ' + - 'parseErrorStartEventDefinition, parseEndEvents, ' + - 'parseOperatonErrorEventDefinitions), and the document written back ' + - 'declares an error root carrying it.', + `'errorRef' '${written}' on '${ownerId}' names no 'bpmn:error' root ` + + `and imports as the code '${written}'\n` + + "note: Operaton takes a dangling reference's text as the code " + + "('BpmnParse.parseBoundaryErrorEventDefinition', " + + "'parseErrorStartEventDefinition', 'parseEndEvents', " + + "'parseOperatonErrorEventDefinitions')\n" + + 'note: the rebuilt document declares an error root that carries it', }); return written; } @@ -2792,12 +3083,15 @@ function readDanglingErrorCode( * Unlike an error, an escalation reference the engine cannot resolve fails the * deployment. */ -function danglingEscalationDetail(written: string, method: string): string { - return ( - `its escalationRef '${written}' names no bpmn:escalation root, which ` + - 'Operaton refuses to deploy ("could not find escalation with id ' + - `'${written}'", BpmnParse.${method})` - ); +function danglingEscalationDetail( + written: string, + method: string, +): [detail: string, note: string] { + return [ + `its escalation reference ('escalationRef="${written}"') names no 'bpmn:escalation' root`, + 'note: Operaton refuses to deploy it ("could not find escalation with id ' + + `'${written}'", 'BpmnParse.${method}')`, + ]; } /** @@ -2812,10 +3106,11 @@ function readLinkDefinition( if (linkName === undefined) { throw new UnsupportedEventFeatureError( ownerId, - 'a link definition carries no name; the name is what a link throw and ' + - 'its catch match on, so one without it has nothing to match', - 'Give the link definition a name, and the same name to the throw and ' + - 'the catch it joins.', + 'a link definition carries no name', + 'note: the name is what a link throw and its catch match on, so one ' + + 'without it has nothing to match', + 'help: give the link definition a name, and the same name to the ' + + 'throw and the catch it joins', ); } return { kind: 'link', linkName }; @@ -2857,15 +3152,15 @@ function refuseUnsupportedCompensateFeatures( throw new UnsupportedEventFeatureError( ownerId, 'a compensation definition targets one activity by reference ' + - `(activityRef="${requireId(activityRef)}"); this tool always ` + - 'addresses the enclosing scope and cannot target a single activity', + `('activityRef="${requireId(activityRef)}"')`, + 'note: BPMNscript always addresses the enclosing scope and cannot target a single activity', ); } if (defEl.get('waitForCompletion') === false) { throw new UnsupportedEventFeatureError( ownerId, - 'a compensation definition sets waitForCompletion="false"; this tool ' + - 'only imports the default (wait for the compensation to complete) behavior', + `a compensation definition sets 'waitForCompletion="false"'`, + 'note: BPMNscript only imports the default behaviour, which waits for the compensation to complete', ); } } @@ -2887,17 +3182,17 @@ function resolveNamedRootRef( if (written !== undefined) { throw new UnsupportedEventFeatureError( ownerId, - `its ${refProperty} '${written}' names no ${rootKind} root: this tool ` + - 'matches the reference to a root id as written, where Operaton ' + - 'resolves a prefixed reference through the xmlns table ' + - '(BpmnParse.resolveName) and refuses an unresolved one', + `its ${label} reference ('${refProperty}="${written}"') names no '${rootKind}' root`, + 'note: BPMNscript matches the reference to a root id as written', + 'note: Operaton resolves a prefixed reference through the xmlns table ' + + "('BpmnParse.resolveName') and refuses an unresolved one", ); } const name = ref ? readString(ref, 'name') : undefined; if (name === undefined) { throw new UnsupportedEventFeatureError( ownerId, - `a ${label} definition must reference a ${rootKind} root with a non-empty name`, + `a ${label} definition must reference a '${rootKind}' root with a non-empty name`, ); } return name; @@ -2908,7 +3203,8 @@ const TIMER_CHILD_TO_KIND = invert(TIMER_KIND_TO_CHILD); function readTimerDefinition( defEl: ModdleElement, ownerId: string, -): { timerKind: TimerKind; expression: string } { + warnings: ImportWarning[], +): Omit, 'kind'> { const childNames = Object.keys( TIMER_CHILD_TO_KIND, ) as (keyof typeof TIMER_CHILD_TO_KIND)[]; @@ -2919,8 +3215,8 @@ function readTimerDefinition( if (present.length !== 1) { throw new UnsupportedEventFeatureError( ownerId, - 'a timer definition must carry exactly one of timeDuration/timeDate/' + - `timeCycle (found ${present.length})`, + "a timer definition must carry exactly one of 'timeDuration', 'timeDate' " + + `and 'timeCycle' (found ${present.length})`, ); } @@ -2930,10 +3226,19 @@ function readTimerDefinition( if (expression === undefined) { throw new UnsupportedEventFeatureError( ownerId, - `a timer definition's ${childName} has an empty body`, + `the '${childName}' of a timer definition has an empty body`, ); } - return { timerKind: TIMER_CHILD_TO_KIND[childName], expression }; + const expressionDocumentation = readDocumentation( + expressionEl, + ownerId, + warnings, + ); + return { + timerKind: TIMER_CHILD_TO_KIND[childName], + expression, + ...defined({ expressionDocumentation }), + }; } /** @@ -2949,13 +3254,13 @@ function readConditionalDefinition( defEl: ModdleElement, ownerId: string, warnings: ImportWarning[], -): string { +): Omit, 'kind'> { for (const localName of CONDITIONAL_NARROWING_ATTRS) { if (defEl.$attrs[`operaton:${localName}`] !== undefined) { throw new UnsupportedEventFeatureError( ownerId, - `a conditional definition's operaton:${localName} narrows when the ` + - 'condition is (re-)evaluated, which this tool cannot represent', + `a conditional definition sets 'operaton:${localName}'`, + 'note: it narrows when the condition is evaluated again, which BPMNscript cannot represent', ); } } @@ -2971,8 +3276,16 @@ function readConditionalDefinition( 'a conditional definition must carry a condition with a non-empty body', ); } - noteRewrappedExpression(condition, ownerId, 'bpmn:condition', warnings); - return condition; + noteRewrappedExpression(condition, ownerId, "'bpmn:condition'", warnings); + const conditionDocumentation = readDocumentation( + conditionEl!, + ownerId, + warnings, + ); + return { + condition, + ...defined({ conditionDocumentation }), + }; } /** @@ -2986,9 +3299,7 @@ function readThrowEventDefinition( ): EventDefinition { // Every position reaching here runs `parseSignalEventDefinition` with // `isThrowing=true`, which reads `async` and `operaton:in`. - collectExtensionDrops(defEl, ownerId, warnings, 'throw'); - collectUnmappedBpmnDrops(defEl, ownerId, warnings); - warnDocumentationDrop(defEl, ownerId, 'an event definition', warnings); + sweepEventDefinition(defEl, ownerId, warnings, 'throw'); warnThrowSideBindingAttrs(defEl, ownerId, warnings); if (defEl.$type === 'bpmn:ErrorEventDefinition') { @@ -2998,19 +3309,21 @@ function readThrowEventDefinition( if (errorCode !== undefined) return { kind: 'error', errorCode }; throw new UnsupportedEventFeatureError( ownerId, - 'its error definition carries no errorRef, which Operaton refuses ' + - "to deploy (\"'errorRef' attribute is mandatory on error end " + - 'event", BpmnParse.parseEndEvents)', + "its error definition carries no 'errorRef'", + "note: Operaton refuses to deploy it (\"'errorRef' attribute is mandatory on error end " + + `event", 'BpmnParse.parseEndEvents')`, + THROW_CODE_NOTE, ); } const errorCode = readString(ref, 'errorCode'); if (errorCode === undefined) { throw new UnsupportedEventFeatureError( ownerId, - `its errorRef names the bpmn:error root '${ref.id}', which carries ` + - "no code; Operaton refuses to deploy the throw (\"'errorCode' is " + + `its error reference names a root that carries no code ('${ref.id}')`, + "note: Operaton refuses to deploy the throw (\"'errorCode' is " + 'mandatory on errors referenced by throwing error event ' + - 'definitions", BpmnParse.parseEndEvents)', + `definitions", 'BpmnParse.parseEndEvents')`, + THROW_CODE_NOTE, ); } return { kind: 'error', errorCode }; @@ -3026,28 +3339,35 @@ function readThrowEventDefinition( const ref = getEl(defEl, 'escalationRef'); if (ref === undefined) { const written = unresolvedRef(defEl, 'escalationRef'); - throw new UnsupportedEventFeatureError( - ownerId, + const [detail, note] = written === undefined - ? 'its escalation definition carries no escalationRef, which ' + - 'Operaton refuses to deploy ("escalationEventDefinition does ' + - "not have required attribute 'escalationRef'\", " + - 'BpmnParse.findEscalationForEscalationEventDefinition)' + ? [ + "its escalation definition carries no 'escalationRef'", + 'note: Operaton refuses to deploy it ("escalationEventDefinition does ' + + "not have required attribute 'escalationRef'\", " + + "'BpmnParse.findEscalationForEscalationEventDefinition')", + ] : danglingEscalationDetail( written, 'findEscalationForEscalationEventDefinition', - ), + ); + throw new UnsupportedEventFeatureError( + ownerId, + detail, + note, + THROW_CODE_NOTE, ); } const escalationCode = readString(ref, 'escalationCode'); if (escalationCode === undefined) { throw new UnsupportedEventFeatureError( ownerId, - `its escalationRef names the bpmn:escalation root '${ref.id}', which ` + - 'carries no code; Operaton refuses to deploy a throw of one ' + + `its escalation reference names a root that carries no code ('${ref.id}')`, + 'note: 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)", + "'BpmnParse.parseIntermediateThrowEvent', and \"escalation end event must " + + "have an 'escalationCode'\", 'parseEndEvents')", + THROW_CODE_NOTE, ); } return { kind: 'escalation', escalationCode }; @@ -3075,8 +3395,8 @@ function warnThrowSideBindingAttrs( elementId: ownerId, category: 'extensionAttribute', message: - `The '${name}' setting on '${ownerId}' only takes effect on a ` + - "catch (an 'on' handler); it has no effect on a throw and was not imported.", + `setting '${name}' on '${ownerId}' was not imported\n` + + "note: it only takes effect on a catch (an 'on' handler), and has no effect on a throw", }); } } @@ -3099,22 +3419,22 @@ function warnLinkThrowEngineSettings( elementId: id, category: 'extensionAttribute', message: - `The ${what} on '${id}' was not imported: Operaton creates no ` + - `activity for a link throw, so it never ${does} one.`, + `${what} on '${id}' was not imported\n` + + `note: Operaton creates no activity for a link throw, so it never ${does} one`, }); }; for (const key of Object.keys(settings)) { - report(`'${key}' setting`, 'reads a setting on'); + report(`setting '${key}'`, 'reads a setting on'); } for (const listener of executionListeners ?? []) { - report(`'${listener.event}' execution listener`, 'runs a listener on'); + report(`execution listener '${listener.event}'`, 'runs a listener on'); } for (const block of extensionValues(el)) { if (block.$type === 'operaton:Properties') { - report('operaton:properties block', 'reads a property list on'); + report("'operaton:properties' block", 'reads a property list on'); } if (block.$type === 'operaton:InputOutput') { - report('operaton:inputOutput block', 'reads a mapping on'); + report("'operaton:inputOutput' block", 'reads a mapping on'); } } } @@ -3131,14 +3451,13 @@ function warnCatchSideImplementationAttrs( elementId: ownerId, category: 'extensionAttribute', message: - `The ${what} on '${ownerId}' only takes effect on a throw ` + - "('throw message' or 'emit message'); it has no effect on a catch and " + - 'was not imported.', + `${what} on '${ownerId}' was not imported\n` + + "note: it only takes effect on a throw ('throw message' or 'emit message'), and has no effect on a catch", }); }; for (const name of [...IMPLEMENTATION_ATTRS, ...IMPLEMENTATION_EXTRA_ATTRS]) { if (readNamespacedAttr(defEl, name) !== undefined) { - report(`'${name}' setting`); + report(`setting '${name}'`); } } for (const child of extensionValues(defEl)) { @@ -3169,21 +3488,29 @@ function warnElidedNamedDrop( subject: string, noun: string = category, ): void => { - const message = isStart - ? `The ${subject} on '${el.id}' was not written to the script: ` + - `'${el.id}' is the kind of name this tool generates for itself, ` + - `which a script cannot repeat, so this start is left out ` + - `entirely and its ${noun} with it. Rename it in the diagram to ` + - `keep the ${noun}.` - : `The ${subject} on '${el.id}' cannot be kept as written: ` + - `'${el.id}' is the kind of name this tool generates for itself, ` + - `which a script cannot repeat. Where the script can do without ` + - `this end, it is left out and its ${noun} with it; anywhere else ` + - `it prints under that name and is refused when read back. ` + - `Rename it in the diagram to keep the ${noun}.`; + const reserved = + `note: '${el.id}' is the kind of name BPMNscript generates for ` + + 'itself, which a script cannot repeat'; + const help = `help: rename it in the diagram to keep the ${noun}`; + const message = ( + isStart + ? [ + `${subject} on '${el.id}' was not written to the script`, + reserved, + `note: this start is left out entirely, and its ${noun} with it`, + help, + ] + : [ + `${subject} on '${el.id}' cannot be kept as written`, + reserved, + `note: where the script can do without this end, it is left out, and its ${noun} with it`, + 'note: anywhere else it prints under that name and is refused when read back', + help, + ] + ).join('\n'); warnings.push({ elementId: el.id, category, message }); }; - if (el.name !== undefined) report('label', `label '${el.name}'`); + if (el.name !== undefined) report('label', `label '${oneLine(el.name)}'`); if (el.documentation !== undefined) report('documentation', 'documentation'); if (el.kind === 'startEvent' && el.initiator !== undefined) { report('extensionAttribute', "'operaton:initiator' setting", 'initiator'); @@ -3195,16 +3522,18 @@ function warnNamedDrop( id: string, surface: string, warnings: ImportWarning[], - derived?: string, + linkName?: string, ): void { - const label = readDerivableName(el, id, derived); - if (label !== undefined) { + // The export writes no label here except a link's name, so any other name, + // even one equal to the id in words, is lost. + const label = readText(el, 'name'); + if (label !== undefined && label !== linkName) { warnings.push({ elementId: id, category: 'label', message: - `The label '${label}' on '${id}' was not imported: ${surface} has no ` + - "label in this tool's surface.", + `label '${oneLine(label)}' on '${id}' was not imported\n` + + `note: ${surface} has no label in BPMNscript`, }); } warnDocumentationDrop(el, id, surface, warnings); @@ -3221,8 +3550,8 @@ function warnDocumentationDrop( elementId: id, category: 'documentation', message: - `The documentation on '${id}' was not imported: ${surface} has no ` + - "documentation in this tool's surface.", + `documentation on '${id}' was not imported\n` + + `note: ${surface} has no documentation in BPMNscript`, }); } @@ -3238,22 +3567,25 @@ function mapCallActivity( // a case call. const caseRef = readNamespacedAttr(el, 'caseRef'); if (calledElement === undefined) { - throw new UnsupportedCallActivityError( - id, - caseRef === undefined - ? 'it names neither a calledElement nor an operaton:caseRef, which ' + - 'BpmnParse.parseCallActivity refuses to deploy ("Missing ' + - "attribute 'calledElement' or 'caseRef'\")" - : `it names operaton:caseRef="${caseRef}" and no calledElement, so ` + - 'BpmnParse.parseCallActivity runs a case through ' + - 'CaseCallActivityBehavior, which this surface has no form for', - ); + throw caseRef === undefined + ? new UnsupportedCallActivityError( + id, + "it names neither a 'calledElement' nor an 'operaton:caseRef'", + "note: 'BpmnParse.parseCallActivity' refuses to deploy it (\"Missing " + + "attribute 'calledElement' or 'caseRef'\")", + ) + : new UnsupportedCallActivityError( + id, + `it names 'operaton:caseRef="${caseRef}"' and no 'calledElement'`, + "note: 'BpmnParse.parseCallActivity' runs a case through " + + "'CaseCallActivityBehavior', which BPMNscript has no form for", + ); } if (caseRef !== undefined) { throw new UnsupportedCallActivityError( id, - `it names a calledElement beside operaton:caseRef="${caseRef}", which ` + - 'BpmnParse.parseCallActivity refuses to deploy ("The attributes ' + + `it names a 'calledElement' beside 'operaton:caseRef="${caseRef}"'`, + "note: 'BpmnParse.parseCallActivity' refuses to deploy it (\"The attributes " + "'calledElement' or 'caseRef' cannot be used together\")", ); } @@ -3261,7 +3593,7 @@ function mapCallActivity( if (tenantId !== undefined) { throw new UnsupportedCallActivityError( id, - tenantPinDetail('calledElementTenantId', tenantId, 'called process'), + ...tenantPinDetail('calledElementTenantId', tenantId, 'called process'), ); } @@ -3269,7 +3601,8 @@ function mapCallActivity( el, id, 'calledElement', - (detail) => new UnsupportedCallActivityError(id, detail), + (detail, ...notes) => + new UnsupportedCallActivityError(id, detail, ...notes), warnings, ); const { businessKey, inMappings, outMappings } = readCallMappings( @@ -3284,11 +3617,7 @@ function mapCallActivity( id, ...named, calledElement, - ...(binding === undefined ? {} : { binding }), - ...(businessKey === undefined ? {} : { businessKey }), - ...(mapper === undefined ? {} : { mapper }), - ...(inMappings === undefined ? {} : { inMappings }), - ...(outMappings === undefined ? {} : { outMappings }), + ...defined({ binding, businessKey, mapper, inMappings, outMappings }), ...readEngineAttributes(el, id, warnings), ...readIoMapping(el, id, warnings), }; @@ -3313,7 +3642,7 @@ function readCallVariableMapper( buildShadowedImplementationWarning( id, delegateAttr, - `operaton:${classAttr}`, + `'operaton:${classAttr}'`, ), ); } @@ -3324,12 +3653,19 @@ function readCallVariableMapper( return undefined; } -function tenantPinDetail(attr: string, value: string, what: string): string { - return ( - `it names operaton:${attr}="${value}", which pins the tenant ` + - `BpmnParse.parseTenantId resolves the ${what} against; dropping it ` + - `would change which ${what} runs, and this surface has no tenant setting` - ); +/** Builds a refusal from a short cause and the `note:`/`help:` lines after it. */ +type Refusal = (detail: string, ...notes: string[]) => Error; + +function tenantPinDetail( + attr: string, + value: string, + what: string, +): [detail: string, ...notes: string[]] { + return [ + `it names 'operaton:${attr}="${value}"'`, + `note: it pins the tenant 'BpmnParse.parseTenantId' resolves the ${what} against`, + `note: dropping it would change which ${what} runs, and BPMNscript has no tenant setting`, + ]; } /** @@ -3340,7 +3676,7 @@ function readVersionBinding( el: ModdleElement, id: string, prefix: 'calledElement' | 'decisionRef' | 'formRef', - refusal: (detail: string) => Error, + refusal: Refusal, warnings: ImportWarning[], ): VersionBinding | undefined { const bindingValue = readNamespacedAttr(el, `${prefix}Binding`); @@ -3352,40 +3688,38 @@ function readVersionBinding( binding = undefined; break; case 'latest': - binding = { kind: 'latest' }; - break; case 'deployment': - binding = { kind: 'deployment' }; + binding = { kind: bindingValue }; break; case 'version': if (version === undefined) { throw refusal( - `${prefix}Binding="version" is set without a ${prefix}Version, so ` + - 'the engine cannot resolve which version to use', + `'${prefix}Binding="version"' is set without a '${prefix}Version'`, + 'note: the engine cannot resolve which version to use', ); } binding = { kind: 'version', version }; break; case 'versionTag': - throw refusal( - prefix === 'formRef' - ? formRefBindingRefusal(bindingValue) - : `${prefix}Binding="versionTag" pins a version tag, which this ` + - 'surface has no setting for', - ); + throw prefix === 'formRef' + ? refusal(...formRefBindingRefusal(bindingValue)) + : refusal( + `'${prefix}Binding="versionTag"' pins a version tag`, + 'note: BPMNscript has no setting for a version tag', + ); default: if (prefix === 'formRef') - throw refusal(formRefBindingRefusal(bindingValue)); + throw refusal(...formRefBindingRefusal(bindingValue)); binding = { kind: 'latest' }; warnings.push({ elementId: id, category: 'rewritten', message: - `The ${prefix}Binding="${bindingValue}" on '${id}' imports as ` + - 'binding: latest: BpmnParse.parseBinding sets no binding for that ' + - 'word and BaseCallableElement.isLatestBinding reads none as ' + - `latest, and this tool writes it back as ${prefix}Binding="latest", ` + - 'which the engine reads the same.', + `'${prefix}Binding="${bindingValue}"' on '${id}' imports as 'binding: latest'\n` + + "note: 'BpmnParse.parseBinding' sets no binding for that word, and " + + "'BaseCallableElement.isLatestBinding' reads none as latest\n" + + `note: BPMNscript writes it back as '${prefix}Binding="latest"', ` + + 'which the engine reads the same', }); break; } @@ -3395,20 +3729,20 @@ function readVersionBinding( elementId: id, category: 'extensionAttribute', message: - `The '${prefix}Version' setting on '${id}' has no effect without ` + - `${prefix}Binding="version" and was not imported.`, + `setting '${prefix}Version' on '${id}' was not imported\n` + + `note: it has no effect without '${prefix}Binding="version"'`, }); } return binding; } -function formRefBindingRefusal(word: string): string { - return ( - `formRefBinding="${word}" is outside the bindings ` + - 'BpmnParse.parseFormDefinition resolves (deployment, latest, version), ' + - 'so the engine refuses to deploy it' - ); +function formRefBindingRefusal(word: string): [string, string] { + return [ + `'formRefBinding="${word}"' is outside the bindings ` + + "'BpmnParse.parseFormDefinition' resolves", + "note: it resolves 'deployment', 'latest' and 'version', and the engine refuses to deploy any other", + ]; } interface CallMappings { @@ -3429,8 +3763,8 @@ function warnMappingAttrIgnored( elementId: ownerId, category: 'extensionAttribute', message: - `The '${attr}' on an ${tag} of '${ownerId}' has no effect alongside ` + - `${winner} and was not imported: ${why}.`, + `'${attr}' on an '${tag}' of '${ownerId}' was not imported\n` + + `note: it has no effect alongside ${winner}, since ${why}`, }); } @@ -3458,10 +3792,10 @@ function readCallMappings( elementId: id, category: 'extensionAttribute', message: - `The operaton:in businessKey="${businessKey}" on '${id}' has ` + - 'no effect alongside a later one and was not imported: ' + - 'BpmnParse.parseInputParameter hands each to ' + - 'setBusinessKeyValueProvider, and the last stands.', + `'operaton:in businessKey="${businessKey}"' on '${id}' was not imported\n` + + 'note: it has no effect alongside a later one\n' + + "note: 'BpmnParse.parseInputParameter' hands each to " + + "'setBusinessKeyValueProvider', and the last stands", }); } const ignored = [ @@ -3476,8 +3810,8 @@ function readCallMappings( id, 'operaton:in', attr, - 'businessKey', - 'BpmnParse.parseInputParameter reads the business key alone off ' + + "'businessKey'", + "'BpmnParse.parseInputParameter' reads the business key alone off " + 'that element', ); } @@ -3495,7 +3829,7 @@ function readCallMappings( } return { - ...(businessKey === undefined ? {} : { businessKey }), + ...defined({ businessKey }), ...(inMappings.length > 0 ? { inMappings } : {}), ...(outMappings.length > 0 ? { outMappings } : {}), }; @@ -3523,16 +3857,15 @@ function readCallVariableMapping( if (variables !== 'all') { throw new UnsupportedCallActivityError( ownerId, - `an ${tag} carries variables="${variables}", which this tool cannot ` + - 'import (only variables="all" is supported)', + `an '${tag}' carries a variables value other than all ('variables="${variables}"')`, ); } for (const attr of ['source', 'sourceExpression', 'target']) { if (readString(value, attr) === undefined) continue; ignored( attr, - 'variables="all"', - 'BpmnParse.parseCallableElementProvider passes every variable and ' + + `'variables="all"'`, + "'BpmnParse.parseCallableElementProvider' passes every variable and " + 'reads nothing else', ); } @@ -3542,8 +3875,8 @@ function readCallVariableMapping( if (value.get('source') === '') { throw new UnsupportedCallActivityError( ownerId, - `an ${tag} carries source="", which ` + - 'BpmnParse.parseCallableElementProvider refuses to deploy ("Empty ' + + `an '${tag}' carries an empty source ('source=""')`, + "note: 'BpmnParse.parseCallableElementProvider' refuses to deploy it (\"Empty " + "attribute 'source' when passing variables\")", ); } @@ -3552,16 +3885,18 @@ function readCallVariableMapping( if (sourceExpression !== undefined) { ignored( 'sourceExpression', - 'source', - 'BpmnParse.parseCallableElementProvider reads source first', + "'source'", + "'BpmnParse.parseCallableElementProvider' reads 'source' first", ); } if (target === undefined) { throw new UnsupportedCallActivityError( ownerId, - `an ${tag} carries source without a target`, + `an '${tag}' carries 'source' without a 'target'`, ); } + refuseUnspellableMappingName(ownerId, tag, 'source', source); + refuseUnspellableMappingName(ownerId, tag, 'target', target); return { kind: 'variable', source, target, ...local }; } @@ -3569,19 +3904,29 @@ function readCallVariableMapping( if (target === undefined) { throw new UnsupportedCallActivityError( ownerId, - `an ${tag} carries sourceExpression without a target`, + `an '${tag}' carries 'sourceExpression' without a 'target'`, ); } + refuseUnspellableMappingName(ownerId, tag, 'target', target); return { kind: 'expression', sourceExpression, target, ...local }; } throw new UnsupportedCallActivityError( ownerId, - `an ${tag} carries none of the recognized shapes (source+target, ` + - 'sourceExpression+target, variables="all", or businessKey)', + `an '${tag}' carries none of the recognized shapes`, ); } +function refuseUnspellableMappingName( + ownerId: string, + tag: 'operaton:in' | 'operaton:out', + attr: 'source' | 'target', + name: string, +): void { + if (isPlainName(name)) return; + throw new UnsupportedVariableNameError(ownerId, `its '${tag}' ${attr}`, name); +} + function collectLaneDrops( processEl: ModdleElement, processId: string, @@ -3604,8 +3949,8 @@ function warnLaneSetDrops( elementId: laneId, category: 'lane', message: - `Lane ${laneName ? `'${laneName}' ` : ''}(${laneId}) was not imported; ` + - 'every step is placed in a single flat process.', + `lane ${laneName ? `'${laneName}' ` : ''}(${laneId}) was not imported\n` + + 'note: every step is placed in a single flat process', }); const childLaneSet = getEl(lane, 'childLaneSet'); if (childLaneSet !== undefined) { @@ -3648,8 +3993,9 @@ function collectUnmappedBpmnDrops( elementId: ownerId, category: 'unmappedConstruct', message: - `The '${key}' attribute on '${ownerId}' is not declared by BPMN and ` + - `was not imported ${IMPORTED_FLOW_NOTE}`, + `attribute '${key}' on '${ownerId}' was not imported\n` + + 'note: BPMN does not declare it\n' + + IMPORTED_FLOW_NOTE, }); } @@ -3661,9 +4007,9 @@ function collectUnmappedBpmnDrops( elementId: ownerId, category: 'unmappedConstruct', message: - `The eventDefinitionRef '${ref.id}' on '${ownerId}' was not ` + - 'imported: BpmnParse reads only the event definitions nested in ' + - 'the event, so the document written back runs the same.', + `'eventDefinitionRef' '${ref.id}' on '${ownerId}' was not imported\n` + + "note: 'BpmnParse' reads only the event definitions nested in " + + 'the event, so the rebuilt document runs the same', }); } continue; @@ -3683,8 +4029,8 @@ function collectUnmappedBpmnDrops( elementId: ownerId, category: 'unmappedConstruct', message: - `A ${describeUnmapped(child, tag)} on '${ownerId}' was not ` + - `imported ${IMPORTED_FLOW_NOTE}`, + `${describeUnmapped(child, tag)} on '${ownerId}' was not imported\n` + + IMPORTED_FLOW_NOTE, }); } } @@ -3714,7 +4060,7 @@ function collectRootDrops( warnDocumentationDrop( root, rootId, - `a ${xmlTagOf(root.$type)} root`, + `a '${xmlTagOf(root.$type)}' root`, warnings, ); } @@ -3724,7 +4070,7 @@ function collectRootDrops( elementId: root.id ?? processId, category: 'unmappedConstruct', message: - `A ${describeUnmapped(root)} root element was not imported ` + + `root ${describeUnmapped(root)} was not imported\n` + IMPORTED_FLOW_NOTE, }); if (root.id !== undefined) reportedRootIds.add(root.id); @@ -3735,14 +4081,16 @@ function collectRootDrops( function describeUnmapped(el: ModdleElement, tag?: string): string { const identity = el.id ?? readString(el, 'name'); const name = tag ?? xmlTagOf(el.$type); - return identity === undefined ? name : `${name} '${identity}'`; + return identity === undefined + ? `element '${name}'` + : `'${name}' element '${identity}'`; } export function dataConstructDropMessage(subject: string): string { return ( - `A ${subject} was not imported: Operaton keeps process variables in ` + - 'its own store and never dispatches on it, so the imported process ' + - 'runs identically.' + `${subject} was not imported\n` + + 'note: Operaton keeps process variables in its own store and never ' + + 'dispatches on it, so the imported process runs the same' ); } @@ -3792,19 +4140,19 @@ function warnUnreadPrefixedAttrs( elementId: ownerId, category: 'extensionAttribute', message: - `The '${key}' setting on '${ownerId}' was not imported: Operaton ` + - 'reads a retry cycle as an ' + + `setting '${key}' on '${ownerId}' was not imported\n` + + "note: Operaton reads a retry cycle as an 'operaton:failedJobRetryTimeCycle' " + 'element and never as an attribute ' + - '(DefaultFailedJobParseListener.setFailedJobRetryTimeCycleValue ' + - 'through BpmnParseUtil.findOperatonExtensionElement), so the ' + - 'document written back runs the same.', + "('DefaultFailedJobParseListener.setFailedJobRetryTimeCycleValue' " + + "through 'BpmnParseUtil.findOperatonExtensionElement')\n" + + 'note: the rebuilt document runs the same', }); continue; } warnUnimportedSetting( warnings, ownerId, - `'${key}' setting`, + `setting '${key}'`, el.$type, localName, qualifier, @@ -3813,7 +4161,7 @@ function warnUnreadPrefixedAttrs( } const FIELD_HAS_NO_HOME = - 'this tool carries an injected field on the step or the listener whose ' + + 'BPMNscript 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'; @@ -3844,7 +4192,7 @@ function warnUnreadExtensionElements( value, ownerId, `'${ownerId}'`, - FIELD_HAS_NO_HOME, + [FIELD_HAS_NO_HOME], warnings, ); continue; @@ -3880,7 +4228,7 @@ function warnUnreadDeclaredAttrs( warnUnimportedSetting( warnings, ownerId, - `'${prop.ns.name}' setting`, + `setting '${prop.ns.name}'`, el.$type, prop.ns.localName, qualifier, @@ -3895,11 +4243,11 @@ export function unimportedSettingMessage( reads?: string, ): string { return ( - `The ${subject} on '${ownerId}' was not imported: this tool reads no ` + - `such setting on ${tag}, ` + + `${subject} on '${ownerId}' was not imported\n` + + `note: BPMNscript reads no such setting on ${tag}\n` + (reads === undefined - ? 'and the document written back carries none.' - : `though Operaton ${reads}, so the document written back runs without it.`) + ? 'note: the rebuilt document carries none' + : `note: Operaton ${reads}, so the rebuilt document runs without it`) ); } @@ -3927,7 +4275,7 @@ function warnUnimportedSetting( } function describeTag(type: string): string { - return `${type.startsWith('operaton:') ? 'an' : 'a'} <${xmlTagOf(type)}>`; + return `${type.startsWith('operaton:') ? 'an' : 'a'} '${xmlTagOf(type)}'`; } /** @@ -3989,7 +4337,9 @@ function describeChild(el: ModdleElement, key: string): string { .map((attr) => readString(el, attr)) .find((value) => value !== undefined); const tag = xmlTagOf(el.$type); - return identity === undefined ? `an ${tag}` : `an ${tag} '${identity}'`; + return identity === undefined + ? `an '${tag}'` + : `an '${tag}' named '${identity}'`; } function xmlTagOf(type: string): string { @@ -4038,9 +4388,10 @@ function noteRewrappedExpression( elementId: ownerId, category: 'rewritten', message: - `The ${slot} on '${ownerId}' is written with "#{...}"; the script ` + - 'prints its body as bare DSL and the rebuilt document writes it ' + - 'inside "${...}", which Operaton evaluates identically.', + `${slot} on '${ownerId}' is rewritten from '#{...}' to '\${...}'\n` + + 'note: the script prints its body as bare DSL, and the rebuilt ' + + "document writes it inside '${...}'\n" + + 'note: Operaton evaluates both the same', }); } @@ -4052,12 +4403,12 @@ function mapStartEvent( const id = requireId(el); refuseIoMapping(el, id, 'ensureNoIoMappingDefined'); const eventDefinition = readStartTrigger(el, id, warnings, hostKind); - const named = readNamed(el, id, warnings); + const named = readNamed(el, id, warnings, readText(el, 'name')); return { kind: 'startEvent', id, ...named, - ...(eventDefinition === undefined ? {} : { eventDefinition }), + ...defined({ eventDefinition }), ...readStartAttributes(el, id, hostKind, warnings), ...readEngineAttributes(el, id, warnings), }; @@ -4077,8 +4428,7 @@ function readStartAttributes( if (hostKind === 'process') { const formFields = readFormFields(el, id, warnings); return { - ...(formFields === undefined ? {} : { formFields }), - ...(initiator === undefined ? {} : { initiator }), + ...defined({ formFields, initiator }), }; } if (initiator !== undefined) { @@ -4086,22 +4436,22 @@ function readStartAttributes( 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.', + `setting 'operaton:initiator' on '${id}' was not imported\n` + + "note: 'BpmnParse.parseScopeStartEvent' reads no 'operaton:' attribute " + + "off a start that is not the process's own\n" + + "note: 'parseProcessDefinitionStartEvent' reads it on the process's own start alone\n" + + 'note: the rebuilt document runs the same', }); } - if (extensionValues(el).some((v) => v.$type === 'operaton:FormData')) { + if (extensionsOf(el, 'operaton:FormData').length > 0) { warnings.push({ elementId: id, category: 'extensionAttribute', message: - `The operaton:formData block on '${id}' was not imported: ` + - "BpmnParse.parseStartFormHandlers runs for the process's own start " + - 'alone and parseScopeStartEvent reads no form, so the document ' + - 'written back runs the same.', + `'operaton:formData' block on '${id}' was not imported\n` + + "note: 'BpmnParse.parseStartFormHandlers' runs for the process's own " + + "start alone, and 'parseScopeStartEvent' reads no form\n" + + 'note: the rebuilt document runs the same', }); } return {}; @@ -4115,14 +4465,15 @@ const IGNORED_START_SUBJECTS: ReadonlyMap< 'bpmn:ErrorEventDefinition', { subject: 'an error', - remedy: "Catch it with 'on error' inside the scope that raises it.", + remedy: "help: catch it with 'on error' inside the scope that raises it", }, ], [ 'bpmn:EscalationEventDefinition', { subject: 'an escalation', - remedy: "Catch it with 'on escalation' inside the scope that raises it.", + remedy: + "help: catch it with 'on escalation' inside the scope that raises it", }, ], [ @@ -4130,8 +4481,8 @@ const IGNORED_START_SUBJECTS: ReadonlyMap< { subject: 'compensation', remedy: - "Compensation undoes a subprocess's completed work, so it belongs " + - "in an 'on compensation' block inside that subprocess.", + "help: compensation undoes a subprocess's completed work, so put it " + + "in an 'on compensation' block inside that subprocess", }, ], ]); @@ -4162,18 +4513,18 @@ function readStartTrigger( if (hostKind !== 'process') { throw new UnsupportedEventFeatureError( id, - 'a subprocess cannot start on a trigger: Operaton rejects one there ' + - 'when it parses the file; a subprocess is entered from the ' + - 'surrounding process, not by an event of its own', - "Put the trigger on an 'on' handler inside the subprocess if it should " + - 'react to an event.', + 'a subprocess cannot start on a trigger', + 'note: Operaton rejects one there when it parses the file', + 'note: a subprocess is entered from the surrounding process, not by an event of its own', + "help: put the trigger on an 'on' handler inside the subprocess if it should " + + 'react to an event', ); } refuseMultipleEventDefinitions( id, defs, 'a start', - `${formatPlainWordList(START_TRIGGERS)} trigger is supported`, + `${formatWordList(START_TRIGGERS)} trigger is supported`, ); const [defEl] = defs; @@ -4181,10 +4532,9 @@ function readStartTrigger( if (ignored !== undefined) { throw new UnsupportedEventFeatureError( id, - `a process cannot start on ${ignored.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', + `a process cannot start on ${ignored.subject}`, + 'note: Operaton ignores the trigger and starts the process as if none were written', + 'note: importing it would write back a document the engine runs differently from what it says', ignored.remedy, ); } @@ -4199,11 +4549,10 @@ function readStartTrigger( ) { throw new UnsupportedEventFeatureError( id, - `a message start event's message name "${definition.messageName}" 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.', + `the message name '${definition.messageName}' of a message start event is an expression`, + 'note: Operaton rejects an expression there, because a process that ' + + 'has not started yet has no variables to evaluate it against', + 'help: give the message a fixed name', ); } return definition; @@ -4226,7 +4575,7 @@ function mapEndEvent( const defs = eventDefinitionsOf(el); if (defs.length === 0) { - const named = readNamed(el, id, warnings); + const named = readNamed(el, id, warnings, readText(el, 'name')); return { kind: 'endEvent', id, @@ -4238,8 +4587,8 @@ function mapEndEvent( id, defs, 'a throw', - 'terminate, cancel, error, escalation, message, signal, or compensation ' + - 'is supported', + "'terminate', 'cancel', 'error', 'escalation', 'message', 'signal', or " + + "'compensation' is supported", ); const [defEl] = defs; @@ -4248,17 +4597,15 @@ function mapEndEvent( if (carried === 'cancel' && hostKind !== 'transaction') { throw new UnsupportedEventFeatureError( id, - '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.', + 'an end event carries a cancel definition outside a block that can be given up', + "note: Operaton only accepts one directly inside a 'bpmn:transaction', " + + 'and refuses to deploy the file otherwise', + "help: move the end inside a 'bpmn:transaction', or take the cancel " + + 'definition off it', ); } - collectExtensionDrops(defEl, id, warnings); - collectUnmappedBpmnDrops(defEl, id, warnings); - warnDocumentationDrop(defEl, id, 'an event definition', warnings); - const named = readNamed(el, id, warnings); + sweepEventDefinition(defEl, id, warnings); + const named = readNamed(el, id, warnings, readText(el, 'name')); return { kind: 'endEvent', id, @@ -4289,7 +4636,7 @@ function mapEndEvent( kind: 'endEvent', id, eventDefinition, - ...(binding === undefined ? {} : { binding }), + ...defined({ binding }), ...readThrowEventAttributes(el, id, binding, warnings), }; } @@ -4302,9 +4649,9 @@ function refuseEndOutputParameters(el: ModdleElement, id: string): void { if (outputs.length === 0) return; throw new UnsupportedExtensionFormError( id, - 'an operaton:outputParameter on a , which ' + - 'BpmnParse.checkActivityOutputParameterSupported fails the deployment ' + - 'on ("operaton:outputParameter not allowed for element type ' + + "an 'operaton:outputParameter' on a 'bpmn:endEvent'", + "note: 'BpmnParse.checkActivityOutputParameterSupported' fails the deployment " + + 'on it ("operaton:outputParameter not allowed for element type ' + "'endEvent'\")", ); } @@ -4326,13 +4673,12 @@ function mapIntermediateThrowEvent( elementId: id, category: 'rewritten', message: - `The bpmn:intermediateThrowEvent '${id}' carries no event ` + - 'definition and imports as a plain step: ' + - 'BpmnParse.parseIntermediateThrowEvent gives it ' + - 'IntermediateThrowNoneEventActivityBehavior, which only leaves, as ' + - "a task's behaviour does, so token flow, listeners, async and job " + - 'configuration are unchanged, but history and Cockpit will report ' + - "its activity type as 'task' rather than 'intermediateNoneThrowEvent'.", + `intermediate throw event '${id}' carries no event definition and imports as a plain step\n` + + "note: 'BpmnParse.parseIntermediateThrowEvent' gives it " + + "'IntermediateThrowNoneEventActivityBehavior', which only leaves, as " + + "a task's behaviour does\n" + + 'note: token flow, listeners, async and job configuration are unchanged\n' + + "note: history and Cockpit report its activity type as 'task' rather than 'intermediateNoneThrowEvent'", }); return { kind: 'task', @@ -4346,15 +4692,16 @@ function mapIntermediateThrowEvent( id, defs, 'an emit', - `${formatPlainWordList(EMIT_TRIGGERS)} is supported`, + `${formatWordList(EMIT_TRIGGERS)} is supported`, ); const [defEl] = defs; if (defEl.$type === 'bpmn:ErrorEventDefinition') { throw new UnsupportedEventFeatureError( id, - 'an emit cannot carry an error: BPMN has no intermediate error ' + - 'throw; write "throw error" to end the path instead', + 'an emit cannot carry an error', + 'note: BPMN has no intermediate error throw', + "help: write 'throw error' to end the path instead", ); } if (!EMIT_CARRIED_TAGS.includes(defEl.$type)) { @@ -4393,7 +4740,7 @@ function mapIntermediateThrowEvent( kind: 'intermediateThrowEvent', id, eventDefinition, - ...(binding === undefined ? {} : { binding }), + ...defined({ binding }), ...readThrowEventAttributes(el, id, binding, warnings), }; } @@ -4408,15 +4755,14 @@ function readThrowEventAttributes( binding: ServiceTaskBinding | undefined, warnings: ImportWarning[], ): EngineAttributes { - for (const block of extensionValues(el)) { - if (block.$type !== 'operaton:Properties') continue; + extensionsOf(el, 'operaton:Properties').forEach(() => warnThrownMessageExtraDrop( id, - 'operaton:properties block', + "'operaton:properties' block", binding?.kind === 'external' ? 'the event element' : undefined, warnings, - ); - } + ), + ); return readEngineAttributes(el, id, warnings); } @@ -4430,23 +4776,19 @@ function warnThrownMessageExtraDrop( elementId: id, category: 'extensionAttribute', message: - readOff === undefined - ? `The ${what} on '${id}' was not imported: Operaton reads it in ` + - 'parseExternalServiceTask alone, which only a thrown message ' + - 'bound with operaton:type="external" reaches, so the event runs ' + - 'as written without it.' - : `The ${what} on '${id}' was not imported: ` + - `BpmnParse.parseExternalServiceTask reads it off ${readOff} of a ` + - 'thrown message bound with operaton:type="external", and this ' + - "surface's throw has no position for it, so the document written " + - 'back runs without it.', + `${what} on '${id}' was not imported\n` + + (readOff === undefined + ? "note: Operaton reads it in 'parseExternalServiceTask' alone, which only a thrown message " + + `bound with 'operaton:type="external"' reaches\n` + + 'note: the event runs as written without it' + : `note: 'BpmnParse.parseExternalServiceTask' reads it off ${readOff} of a ` + + `thrown message bound with 'operaton:type="external"'\n` + + 'note: the throw in BPMNscript has no position for it, so the rebuilt document runs without it'), }); } function hasConnector(el: ModdleElement): boolean { - return extensionValues(el).some( - (value) => value.$type === 'operaton:Connector', - ); + return extensionsOf(el, 'operaton:Connector').length > 0; } /** @@ -4464,8 +4806,9 @@ function readThrownMessageBinding( if (hasConnector(defEl)) { throw new UnsupportedEventFeatureError( id, - 'a thrown message carries a connector; that is what makes the engine ' + - 'really send it, and this surface has no place to keep it', + 'a thrown message carries a connector', + 'note: the connector is what makes the engine really send it, and BPMNscript has no place to keep it', + ...THROWN_MESSAGE_FORM_LINES, ); } @@ -4479,10 +4822,16 @@ function readThrownMessageBinding( binding === undefined && readNamespacedAttr(defEl, 'type') !== undefined ) { + const [construct, ...notes] = detectUnsupportedServiceTaskForm( + defEl, + 'thrownMessage', + ); throw new UnsupportedServiceTaskFormError( id, - detectUnsupportedServiceTaskForm(defEl, 'thrownMessage'), + construct, 'Thrown message', + ...notes, + ...THROWN_MESSAGE_FORM_LINES, ); } if ( @@ -4493,16 +4842,16 @@ function readThrownMessageBinding( elementId: id, category: 'extensionAttribute', message: - `The 'topic' setting on '${id}' only takes effect alongside ` + - 'operaton:type="external"; on its own it names no external worker ' + - 'and was not imported.', + `setting 'topic' on '${id}' was not imported\n` + + `note: it only takes effect alongside 'operaton:type="external"', and on its own names no external worker`, }); } - const refusal = (construct: string): Error => + const refusal: Refusal = (construct, ...notes) => new UnsupportedEventFeatureError( id, `its message definition binds ${construct}`, + ...notes, ); if (binding !== undefined) { refuseResultVariableBeside(binding, defEl, elementName, refusal); @@ -4511,25 +4860,24 @@ function readThrownMessageBinding( if (resultVariableAttr !== undefined) { if (binding?.kind === 'expression') { throw refusal( - `operaton:expression with operaton:${resultVariableAttr}=` + - `"${readNamespacedAttr(defEl, resultVariableAttr)}", under which ` + - "BpmnParse.parseServiceTaskLike stores the expression's value " + - '(ServiceTaskExpressionActivityBehavior); a thrown message in ' + - 'this script takes no result variable, so dropping it would ' + - 'change what runs', + "an expression with a result variable ('operaton:expression', " + + `'operaton:${resultVariableAttr}="${readNamespacedAttr(defEl, resultVariableAttr)}"')`, + "note: 'BpmnParse.parseServiceTaskLike' stores the expression's value " + + "under it ('ServiceTaskExpressionActivityBehavior')", + 'note: a thrown message in BPMNscript takes no result variable, so dropping it would change what runs', ); } warnings.push({ elementId: id, category: 'extensionAttribute', message: - `The '${resultVariableAttr}' setting on '${id}' was not imported: ` + - 'BpmnParse.parseServiceTaskLike hands it to an expression binding ' + + `setting '${resultVariableAttr}' on '${id}' was not imported\n` + + "note: 'BpmnParse.parseServiceTaskLike' hands it to an expression binding " + `alone, so ${ binding === undefined ? 'a definition naming no implementation' - : 'an operaton:type="external" binding' - } never writes it.`, + : `an 'operaton:type="external"' binding` + } never writes it`, }); } @@ -4540,11 +4888,15 @@ function readThrownMessageBinding( id, where, binding !== undefined && carriesFields(binding) - ? `${FIELD_HAS_NO_HOME}; BpmnParse.parseServiceTaskLike reads it ` + - `off the definition into the ${binding.kind} it names, so the ` + - 'document written back runs that without it' - : `${FIELD_HAS_NO_HOME}, and Operaton injects a field into a class ` + - 'or a delegate binding and into no other', + ? [ + FIELD_HAS_NO_HOME, + "'BpmnParse.parseServiceTaskLike' reads it off the definition " + + `into the ${binding.kind} it names, so the rebuilt document runs that without it`, + ] + : [ + FIELD_HAS_NO_HOME, + 'Operaton injects a field into a class or a delegate binding and into no other', + ], warnings, ); } @@ -4554,16 +4906,15 @@ function readThrownMessageBinding( const taskPriority = readNamespacedAttr(defEl, 'taskPriority'); if (taskPriority !== undefined) { if (external) requireIntegerOrExpression(taskPriority, id, 'taskPriority'); - warnThrownMessageExtraDrop(id, "'taskPriority' setting", readOff, warnings); + warnThrownMessageExtraDrop(id, "setting 'taskPriority'", readOff, warnings); } - for (const mapping of extensionValues(defEl)) { - if (mapping.$type !== 'operaton:ErrorEventDefinition') continue; + for (const mapping of extensionsOf(defEl, 'operaton:ErrorEventDefinition')) { warnThrownMessageExtraDrop(id, childSubject(mapping), readOff, warnings); } return binding; } -const AWAITABLE_TRIGGERS = formatPlainWordList(CATCH_TRIGGERS); +const AWAITABLE_TRIGGERS = formatWordList(CATCH_TRIGGERS); function mapIntermediateCatchEvent( el: ModdleElement, @@ -4576,8 +4927,8 @@ function mapIntermediateCatchEvent( if (el.get('parallelMultiple') === true) { throw new UnsupportedEventFeatureError( id, - 'an await with parallelMultiple="true" waits for several triggers ' + - `together; only a single ${AWAITABLE_TRIGGERS} trigger can be awaited`, + `an await with 'parallelMultiple="true"' waits for several triggers together`, + `note: only a single ${AWAITABLE_TRIGGERS} trigger can be awaited`, ); } @@ -4586,8 +4937,8 @@ function mapIntermediateCatchEvent( if (defs.length === 0) { throw new UnsupportedEventFeatureError( id, - 'an await with no event definition (a "none" intermediate catch) ' + - 'waits for nothing this tool can represent', + 'an await has no event definition', + 'note: a "none" intermediate catch waits for nothing BPMNscript can represent', ); } refuseMultipleEventDefinitions( @@ -4601,11 +4952,11 @@ function mapIntermediateCatchEvent( if (!AWAIT_CARRIED_TAGS.includes(defEl.$type)) { throw new UnsupportedEventFeatureError( id, - `an await cannot carry a ${defEl.$type}: only ${AWAITABLE_TRIGGERS} ` + - 'triggers can be awaited inline; error and ' + - 'escalation are caught by an event handler and raised with ' + - 'throw/emit, compensation is undone by a subprocess block, and a ' + - 'cancel is written on the end that gives up an attempt block', + `an await cannot carry a '${defEl.$type}'`, + `note: only ${AWAITABLE_TRIGGERS} triggers can be awaited inline`, + "note: an error or an escalation is caught by an event handler and raised with 'throw' or 'emit'", + 'note: compensation is undone by a subprocess block', + 'note: a cancel is written on the end that gives up an attempt block', ); } @@ -4637,16 +4988,17 @@ function refuseLoopCharacteristics(el: ModdleElement, id: string): void { throw new UnsupportedLoopCharacteristicsError( id, loop.$type, - 'an event handler is entered by its trigger, so it cannot be repeated', + 'an event handler cannot be repeated', + 'note: an event handler is entered by its trigger', ); } } export function loopDroppedMessage(tag: string, id: string): string { return ( - `The ${tag} on '${id}' was not imported: Operaton does not run one ` + - 'at all, it deploys the step and runs it once, so the imported step ' + - 'runs once too.' + `'${tag}' on '${id}' was not imported\n` + + 'note: Operaton does not run one at all, it deploys the step and runs it once\n' + + 'note: the imported step runs once too' ); } @@ -4679,16 +5031,16 @@ function readLoopCharacteristics( throw new UnsupportedLoopCharacteristicsError( id, MULTI_INSTANCE, - 'it sets neither a number of runs nor a collection to run over, and ' + - 'Operaton refuses to deploy that', + 'it sets neither a number of runs nor a collection to run over', + 'note: Operaton refuses to deploy that', ); } if (elementVariable !== undefined && collection === undefined) { throw new UnsupportedLoopCharacteristicsError( id, MULTI_INSTANCE, - `it names '${elementVariable}' for each run to see but no collection ` + - 'to take it from, and Operaton refuses to deploy that', + `it names '${elementVariable}' for each run to see but no collection to take it from`, + 'note: Operaton refuses to deploy that', ); } sweepRepetition(loopEl, id, warnings); @@ -4704,13 +5056,13 @@ function readLoopCharacteristics( elementId: id, category: 'behaviourChanged', message: - `The bpmn:completionCondition on '${id}' is the bare text ` + - `${JSON.stringify(completionCondition)} with no "\${...}" or ` + - '"#{...}" opener: parseMultiInstanceLoopCharacteristics hands it to ' + - 'createExpression as a literal and ' + - 'MultiInstanceActivityBehavior.completionConditionSatisfied throws ' + - 'expressionNotBooleanException when the first run completes; the ' + - 'script writes it inside "${...}", which evaluates it.', + `'bpmn:completionCondition' on '${id}' is the bare text '${oneLine(completionCondition)}' and is rewritten as an expression\n` + + "note: it has no '${...}' or '#{...}' opener\n" + + "note: 'parseMultiInstanceLoopCharacteristics' hands it to " + + "'createExpression' as a literal, and " + + "'MultiInstanceActivityBehavior.completionConditionSatisfied' throws " + + "'expressionNotBooleanException' when the first run completes\n" + + "note: the script writes it inside '${...}', which evaluates it", }); } const conditionLanguage = @@ -4720,24 +5072,50 @@ function readLoopCharacteristics( elementId: id, category: 'unmappedConstruct', message: - `The language=${JSON.stringify(conditionLanguage)} on the ` + - `bpmn:completionCondition of '${id}' was not imported: ` + - 'parseMultiInstanceLoopCharacteristics hands the text alone to ' + - 'createExpression and reads no language, so the imported step runs ' + - 'the same.', + `'language="${conditionLanguage}"' on the 'bpmn:completionCondition' of '${id}' was not imported\n` + + "note: 'parseMultiInstanceLoopCharacteristics' hands the text alone to " + + "'createExpression' and reads no language\n" + + 'note: the imported step runs the same', }); } noteRewrappedExpression( completionCondition, id, - 'bpmn:completionCondition', + "'bpmn:completionCondition'", + warnings, + ); + const childDocumentation = ( + child: 'loopCardinality' | 'completionCondition', + value: string | undefined, + ): string | undefined => { + const childEl = getEl(loopEl, child); + return value === undefined || childEl === undefined + ? undefined + : readDocumentation(childEl, id, warnings); + }; + const cardinalityDocumentation = childDocumentation( + 'loopCardinality', + cardinality, + ); + const completionConditionDocumentation = childDocumentation( + 'completionCondition', + completionCondition, + ); + const elementVariableDocumentation = readDataItemDocumentation( + loopEl, + id, warnings, ); return { - ...(cardinality === undefined ? {} : { cardinality }), - ...(collection === undefined ? {} : { collection }), - ...(elementVariable === undefined ? {} : { elementVariable }), - ...(completionCondition === undefined ? {} : { completionCondition }), + ...defined({ + cardinality, + cardinalityDocumentation, + collection, + elementVariable, + elementVariableDocumentation, + completionCondition, + completionConditionDocumentation, + }), ...(loopEl.get('isSequential') === true ? { sequential: true } : {}), ...jobSettings({ ...readRunSettings(loopEl, id, warnings), @@ -4758,7 +5136,8 @@ function readCardinality( throw new UnsupportedLoopCharacteristicsError( id, MULTI_INSTANCE, - 'its bpmn:loopCardinality is empty, so Operaton has no number of runs to read', + "its 'bpmn:loopCardinality' is empty", + 'note: Operaton has no number of runs to read', ); } // The printer has no bare form for this literal; it would come back as an @@ -4770,12 +5149,11 @@ function readCardinality( throw new UnsupportedLoopCharacteristicsError( id, MULTI_INSTANCE, - `its bpmn:loopCardinality is ${JSON.stringify(cardinality)}, 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', + `its 'bpmn:loopCardinality' is '${oneLine(cardinality)}', which BPMNscript cannot write back unchanged`, + 'note: BPMNscript writes a count as a plain whole number or as an expression, and this body is neither', ); } - noteRewrappedExpression(cardinality, id, 'bpmn:loopCardinality', warnings); + noteRewrappedExpression(cardinality, id, "'bpmn:loopCardinality'", warnings); return cardinality; } @@ -4800,15 +5178,15 @@ function readCollection( const sameShape = isExpression(setting) === isExpression(referenced); const kept = sameShape || isExpression(referenced) ? referenced : setting; warnShadowedRepetitionField(warnings, id, { - first: 'operaton:collection', - second: 'bpmn:loopDataInputRef', + first: "'operaton:collection'", + second: "'bpmn:loopDataInputRef'", names: 'the collection', rule: sameShape - ? 'parseMultiInstanceLoopCharacteristics writes both into the same ' + - 'field and bpmn:loopDataInputRef second' - : 'parseMultiInstanceLoopCharacteristics stores an expression (a ' + - 'value containing "{") and a variable name in two fields, and ' + - 'MultiInstanceActivityBehavior.resolveNrOfInstances reads the ' + + ? "'parseMultiInstanceLoopCharacteristics' writes both into the same " + + "field, and 'bpmn:loopDataInputRef' second" + : "'parseMultiInstanceLoopCharacteristics' stores an expression (a " + + "value containing '{') and a variable name in two fields, and " + + "'MultiInstanceActivityBehavior.resolveNrOfInstances' reads the " + 'expression field first', kept, dropped: kept === referenced ? setting : referenced, @@ -4816,6 +5194,26 @@ function readCollection( return kept; } +/** Kept only beside the name it documents, which is the element variable. */ +function readDataItemDocumentation( + loopEl: ModdleElement, + id: string, + warnings: ImportWarning[], +): string | undefined { + const dataItem = getEl(loopEl, 'inputDataItem'); + if (dataItem === undefined) return undefined; + if (readString(dataItem, 'name') === undefined) { + warnDocumentationDrop( + dataItem, + id, + "a 'bpmn:inputDataItem' without a name", + warnings, + ); + return undefined; + } + return readDocumentation(dataItem, id, warnings); +} + function readElementVariable( loopEl: ModdleElement, id: string, @@ -4828,25 +5226,24 @@ function readElementVariable( // One field in the engine (`setCollectionElementVariable`), written twice. if (setting !== undefined && itemName !== undefined) { warnShadowedRepetitionField(warnings, id, { - first: 'bpmn:inputDataItem', - second: 'operaton:elementVariable', + first: "'bpmn:inputDataItem'", + second: "'operaton:elementVariable'", names: 'what each run sees', rule: - 'parseMultiInstanceLoopCharacteristics writes both into the same ' + - 'field and bpmn:inputDataItem second', + "'parseMultiInstanceLoopCharacteristics' writes both into the same " + + "field, and 'bpmn:inputDataItem' second", kept: itemName, dropped: setting, }); } const elementVariable = itemName ?? setting; // A name outside the grammar's ID terminal would not parse back. - if (elementVariable !== undefined && !ID_TEXT.test(elementVariable)) { + if (elementVariable !== undefined && !isPlainName(elementVariable)) { throw new UnsupportedLoopCharacteristicsError( id, MULTI_INSTANCE, - `it names ${JSON.stringify(elementVariable)} 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 names '${oneLine(elementVariable)}' for each run to see, which BPMNscript cannot write back unchanged`, + 'note: BPMNscript writes that name as a plain identifier, and this name is not one', ); } return elementVariable; @@ -4868,26 +5265,24 @@ function warnShadowedRepetitionField( elementId: id, category: 'extensionAttribute', message: - `Both ${shadow.first} and ${shadow.second} name ${shadow.names} on ` + - `'${id}'; ${shadow.rule}, so '${shadow.kept}' was imported and ` + - `'${shadow.dropped}' was dropped.`, + `${shadow.first} and ${shadow.second} on '${id}' both name ${shadow.names}, and '${shadow.dropped}' was dropped\n` + + `note: ${shadow.rule}\n` + + `note: '${shadow.kept}' was imported`, }); } /** The engine refuses an output mapping on a repeating step. */ function refuseOutputParameters(el: ModdleElement, id: string): void { - const io = extensionValues(el).find( - (value) => value.$type === 'operaton:InputOutput', - ); + const [io] = extensionsOf(el, 'operaton:InputOutput'); const outputs = (io?.get('outputParameters') as ModdleElement[] | undefined) ?? []; if (outputs.length > 0) { throw new UnsupportedLoopCharacteristicsError( id, MULTI_INSTANCE, - "it maps an 'operaton:outputParameter', which " + - 'BpmnParse.checkActivityOutputParameterSupported fails the ' + - 'deployment on ("operaton:outputParameter not allowed for ' + + "it maps an 'operaton:outputParameter'", + "note: 'BpmnParse.checkActivityOutputParameterSupported' fails the " + + 'deployment on it ("operaton:outputParameter not allowed for ' + 'multi-instance constructs")', ); } @@ -4916,16 +5311,16 @@ function sweepRepetition( getEl(loopEl, name) !== undefined || unresolvedRef(loopEl, name) !== undefined ) { - warnRepetitionContentIgnored(warnings, id, `bpmn:${name}`); + warnRepetitionContentIgnored(warnings, id, `'bpmn:${name}'`); } } const behavior = readString(loopEl, 'behavior'); if (behavior !== undefined && behavior !== 'All') { - warnRepetitionContentIgnored(warnings, id, `behavior="${behavior}"`); + warnRepetitionContentIgnored(warnings, id, `'behavior="${behavior}"'`); } // `createActivityOnScope` reads a job priority off the step alone. if (readNamespacedAttr(loopEl, 'jobPriority') !== undefined) { - warnRepetitionContentIgnored(warnings, id, 'operaton:jobPriority'); + warnRepetitionContentIgnored(warnings, id, "'operaton:jobPriority'"); } } @@ -4938,8 +5333,8 @@ function warnRepetitionContentIgnored( elementId: id, category: 'unmappedConstruct', message: - `The ${construct} on the repetition of '${id}' was not imported: ` + - 'Operaton does not read it, so the imported process runs the same.', + `${construct} on the repetition of '${id}' was not imported\n` + + 'note: Operaton does not read it, so the imported process runs the same', }); } @@ -4960,13 +5355,15 @@ function mapUserTask(el: ModdleElement, warnings: ImportWarning[]): UserTask { id, ...named, ...assignment, - ...(formKey === undefined ? {} : { formKey }), - ...(formRef === undefined ? {} : { formRef }), - ...(formFields === undefined ? {} : { formFields }), - ...(dueDate === undefined ? {} : { dueDate }), - ...(followUpDate === undefined ? {} : { followUpDate }), - ...(priority === undefined ? {} : { priority }), - ...(taskListeners === undefined ? {} : { taskListeners }), + ...defined({ + formKey, + formRef, + formFields, + dueDate, + followUpDate, + priority, + taskListeners, + }), ...readEngineAttributes(el, id, warnings), ...readIoMapping(el, id, warnings), }; @@ -5064,8 +5461,8 @@ function readAssignment( if (performers.length > 1) { throw new UnsupportedAssignmentError( id, - `it carries ${performers.length} bpmn:humanPerformer elements, and ` + - 'BpmnParse.parseHumanPerformer admits one', + `it carries ${performers.length} 'bpmn:humanPerformer' elements`, + "note: Operaton refuses to deploy more than one ('BpmnParse.parseHumanPerformer')", ); } @@ -5079,13 +5476,13 @@ function readAssignment( warnings.push({ elementId: id, category: 'unmappedConstruct', - message: `The ${tag} on '${id}' was not imported: ${reason}.`, + message: `${tag} on '${id}' was not imported\nnote: ${reason}`, }); }; if (reader === undefined) { drop( - 'Operaton reads a bpmn:humanPerformer and a bpmn:potentialOwner by ' + - 'tag (BpmnParse.parseTaskDefinition) and no other resource role', + "Operaton reads a 'bpmn:humanPerformer' and a 'bpmn:potentialOwner' by " + + "tag ('BpmnParse.parseTaskDefinition') and no other resource role", ); continue; } @@ -5093,7 +5490,7 @@ function readAssignment( if (text === undefined) { drop( 'it carries no formal expression, and Operaton reads nothing else ' + - `off it (BpmnParse.${reader})`, + `off it ('BpmnParse.${reader}')`, ); continue; } @@ -5103,10 +5500,10 @@ function readAssignment( if (attrAssignee !== undefined) { throw new UnsupportedAssignmentError( id, - `its ${tag} names an assignee ("${text}") beside ` + - `operaton:assignee="${attrAssignee}", which ` + - 'BpmnParse.parseUserTaskCustomExtensions refuses as a duplicate ' + - 'assignee declaration', + `its ${tag} names an assignee ('${text}') beside ` + + `'operaton:assignee="${attrAssignee}"'`, + "note: 'BpmnParse.parseUserTaskCustomExtensions' refuses it as a " + + 'duplicate assignee declaration', ); } assignee = text; @@ -5124,12 +5521,12 @@ function readAssignment( elementId: id, category: 'rewritten', message: - `The ${tag} on '${id}' imports as ` + - `${became.map(([key, value]) => `${key}: "${value}"`).join(' and ')}: ` + - `Operaton reads its formal expression that way (BpmnParse.${reader}), ` + - 'and this tool writes it back as ' + - `${became.map(([key]) => `operaton:${key}`).join(' and ')}, which ` + - 'the engine reads the same.', + `${tag} on '${id}' imports as ` + + `${became.map(([key, value]) => `'${key}: "${value}"'`).join(' and ')}\n` + + `note: Operaton reads its formal expression that way ('BpmnParse.${reader}')\n` + + 'note: BPMNscript writes it back as ' + + `${became.map(([key]) => `'operaton:${key}'`).join(' and ')}, which ` + + 'the engine reads the same', }); const unread = [ @@ -5146,9 +5543,9 @@ function readAssignment( elementId: id, category: 'unmappedConstruct', message: - `The ${unread.join(' and ')} on the ${tag} on '${id}' was not ` + - `imported: Operaton reads the role's formal expression alone ` + - `(BpmnParse.${reader}).`, + `${unread.map((name) => `'${name}'`).join(' and ')} on the ${tag} on '${id}' was not imported\n` + + "note: Operaton reads the role's formal expression alone " + + `('BpmnParse.${reader}')`, }); } } @@ -5162,9 +5559,7 @@ function readAssignment( readNamespacedAttr(el, 'candidateGroups'), ); return { - ...(assignee === undefined ? {} : { assignee }), - ...(candidateUsers === undefined ? {} : { candidateUsers }), - ...(candidateGroups === undefined ? {} : { candidateGroups }), + ...defined({ assignee, candidateUsers, candidateGroups }), }; } @@ -5189,20 +5584,23 @@ function readFormRef( return undefined; } - const refusal = (detail: string): Error => - new UnsupportedFormReferenceError(id, detail); + const refusal: Refusal = (detail, ...notes) => + new UnsupportedFormReferenceError(id, detail, ...notes); if (readNamespacedAttr(el, 'formKey') !== undefined) { throw refusal( - 'it names an operaton:formKey beside the operaton:formRef, and a task ' + - 'renders one form', + "it names an 'operaton:formKey' beside the 'operaton:formRef'", + 'note: a task renders one form', + 'note: Operaton refuses to deploy a user task that names both a form ' + + 'key and a form reference', ); } const binding = readVersionBinding(el, id, 'formRef', refusal, warnings); if (binding === undefined) { throw refusal( - 'its operaton:formRef carries no operaton:formRefBinding, so the ' + - 'engine cannot resolve which deployed form to render', + "its 'operaton:formRef' carries no 'operaton:formRefBinding'", + 'note: the engine cannot resolve which deployed form to render', + 'note: Operaton refuses to deploy a user task whose form reference has no binding', ); } return { key, binding }; @@ -5222,10 +5620,9 @@ function mapTask(el: ModdleElement, warnings: ImportWarning[]): Task { export function manualTaskMessage(id: string): string { return ( - `The bpmn:manualTask '${id}' imports as a plain step: token flow, ` + - 'waiting, listeners, async and job configuration are all unchanged, ' + - "but history and Cockpit will report its activity type as 'task' " + - "rather than 'manualTask'." + `manual task '${id}' imports as a plain step\n` + + 'note: token flow, waiting, listeners, async and job configuration are all unchanged\n' + + "note: history and Cockpit report its activity type as 'task' rather than 'manualTask'" ); } @@ -5248,16 +5645,18 @@ function mapReceiveTask( ): ReceiveTask { const id = requireId(el); const named = readNamed(el, id, warnings); - // A missing messageRef is a legitimate wait state. + // A missing messageRef is a legitimate wait state, a dangling one is refused + // like on a catch event. const messageName = - getEl(el, 'messageRef') === undefined + getEl(el, 'messageRef') === undefined && + unresolvedRef(el, 'messageRef') === undefined ? undefined : resolveNamedRootRef(el, 'messageRef', id, 'message'); return { kind: 'receiveTask', id, ...named, - ...(messageName === undefined ? {} : { messageName }), + ...defined({ messageName }), ...readEngineAttributes(el, id, warnings), ...readIoMapping(el, id, warnings), }; @@ -5281,10 +5680,10 @@ function mapServiceTask( elementId: id, category: 'carriedAsWritten', message: - `The resultVariable '${resultVariable}' on '${id}' was imported as ` + - 'written, and the printed script draws a warning at the step: ' + - 'BpmnParse.parseServiceTaskLike hands it to an expression binding ' + - 'alone, so an operaton:type binding never writes it.', + `result variable '${resultVariable}' on '${id}' is never written\n` + + 'note: it was imported as written, and the printed script draws a warning at the step\n' + + "note: 'BpmnParse.parseServiceTaskLike' hands it to an expression binding " + + "alone, so an 'operaton:type' binding never writes it", }); } return { @@ -5292,8 +5691,7 @@ function mapServiceTask( id, ...named, binding, - ...(resultVariable === undefined ? {} : { resultVariable }), - ...(element === undefined ? {} : { element }), + ...defined({ resultVariable, element }), ...readEngineAttributes(el, id, warnings), ...readIoMapping(el, id, warnings), }; @@ -5316,7 +5714,7 @@ function readResultVariable( buildShadowedImplementationWarning( id, 'resultVariableName', - 'operaton:resultVariable', + "'operaton:resultVariable'", ), ); return current; @@ -5325,10 +5723,10 @@ function readResultVariable( elementId: id, category: 'rewritten', message: - `The operaton:resultVariableName="${older}" on '${id}' imports as ` + - `resultVariable: "${older}": BpmnParse.parseResultVariable reads the ` + - 'two spellings as one, and this tool writes it back as ' + - 'operaton:resultVariable, which the engine reads the same.', + `'operaton:resultVariableName="${older}"' on '${id}' imports as ` + + `'resultVariable: "${older}"'\n` + + "note: 'BpmnParse.parseResultVariable' reads the two spellings as one\n" + + "note: BPMNscript writes it back as 'operaton:resultVariable', which the engine reads the same", }); return older; } @@ -5343,11 +5741,14 @@ const SERVICE_TASK_LIKE_SUBJECT = { * On a Connect-enabled engine a connector overrides the task's behaviour, so * the file has two possible executions. */ -export const CONNECTOR_CONSTRUCT = - 'an element, which the Connect plugin runs in place ' + - 'of whatever operaton:class, expression, delegateExpression, or type ' + - 'names beside it, and which an engine without the plugin runs instead ' + - 'of, so the same file has two possible executions'; +export const CONNECTOR_CONSTRUCT = "an 'operaton:connector' element"; + +const CONNECTOR_NOTES = [ + "note: the Connect plugin runs it in place of whatever 'operaton:class', " + + "'expression', 'delegateExpression' or 'type' names beside it", + 'note: an engine without the plugin runs that binding instead of it, so ' + + 'the same file has two possible executions', +]; function readServiceTaskBinding( el: ModdleElement, @@ -5356,11 +5757,17 @@ function readServiceTaskBinding( warnings: ImportWarning[], ): ServiceTaskBinding { const subject = SERVICE_TASK_LIKE_SUBJECT[element ?? 'service']; - const refusal = (construct: string): Error => - new UnsupportedServiceTaskFormError(id, construct, subject); + const refusal: Refusal = (construct, ...notes) => + new UnsupportedServiceTaskFormError(id, construct, subject, ...notes); - // First: a connector wins at runtime. - if (hasConnector(el)) throw refusal(CONNECTOR_CONSTRUCT); + // A connector wins at runtime, so it refuses before any binding is read. + if (hasConnector(el)) { + throw refusal( + CONNECTOR_CONSTRUCT, + ...CONNECTOR_NOTES, + ...SERVICE_TASK_FORM_LINES, + ); + } // The decision reference is the engine's discriminator, read before any code // fallback. @@ -5369,7 +5776,10 @@ function readServiceTaskBinding( ? readDecisionBinding(el, id, refusal, warnings) : undefined) ?? readCodeOrExternalBinding(el, id, warnings, 'task'); if (binding === undefined) { - throw refusal(detectUnsupportedServiceTaskForm(el, 'task')); + throw refusal( + ...detectUnsupportedServiceTaskForm(el, 'task'), + ...SERVICE_TASK_FORM_LINES, + ); } refuseResultVariableBeside( binding, @@ -5389,16 +5799,17 @@ function refuseResultVariableBeside( binding: ServiceTaskBinding, el: ModdleElement, elementName: string, - refusal: (construct: string) => Error, + refusal: Refusal, ): void { if (binding.kind !== 'class' && binding.kind !== 'delegateExpression') return; const written = writtenResultVariableAttr(el); if (written === undefined) return; throw refusal( - `operaton:${binding.kind} with operaton:${written}, which Operaton ` + - `refuses to deploy: "'resultVariableName' not supported for ` + + `${binding.kind === 'class' ? 'a Java class' : 'a delegate expression'} with a result variable ` + + `('operaton:${binding.kind}', 'operaton:${written}')`, + `note: Operaton refuses to deploy it with "'resultVariableName' not supported for ` + `${elementName} elements using '${binding.kind}'" ` + - '(BpmnParse.parseServiceTaskLike)', + "('BpmnParse.parseServiceTaskLike')", ); } @@ -5416,22 +5827,21 @@ function writtenResultVariableAttr( function refuseBuiltinShapes( binding: Extract, id: string, - refusal: (construct: string) => Error, + refusal: Refusal, warnings: ImportWarning[], ): void { const { type } = binding; const fields = binding.fields ?? []; - const named = `operaton:type="${type}"`; - const method = `BpmnParse.${BUILTIN_FIELD_VALIDATOR[type]}`; + const named = `'operaton:type="${type}"'`; + const method = `'BpmnParse.${BUILTIN_FIELD_VALIDATOR[type]}'`; if (type === 'shell') { const evaluated = fields.find((field) => EXPRESSION_OPEN.test(field.value)); if (evaluated !== undefined) { throw refusal( - `${named} with the field '${evaluated.name}' written as an ` + - 'operaton:expression, which Operaton fails to deploy: ' + - `${method} casts every shell field to a FixedValue, and an ` + - 'expression is not one', + `${named} with the field '${evaluated.name}' written as an 'operaton:expression'`, + `note: Operaton fails to deploy it, since ${method} casts every shell ` + + "field to a 'FixedValue', and an expression is not one", ); } const flag = fields.find( @@ -5441,8 +5851,8 @@ function refuseBuiltinShapes( ); if (flag !== undefined) { throw refusal( - `${named} with the field '${flag.name}' set to '${flag.value}', ` + - 'which Operaton refuses to deploy: "undefined value for shell ' + + `${named} with the field '${flag.name}' set to '${flag.value}'`, + 'note: Operaton refuses to deploy it with "undefined value for shell ' + `${flag.name} parameter :${flag.value}" (${method})`, ); } @@ -5457,10 +5867,10 @@ function refuseBuiltinShapes( elementId: id, category: 'carriedAsWritten', message: - `The shell field '${field.name}' spelled '${field.value}' on ` + - `'${id}' was imported as written, and the printed script draws an ` + - 'error at the field: ShellActivityBehavior.readFields compares it ' + - 'with "true" case-sensitively, so the engine reads it as false.', + `shell field '${field.name}' on '${id}' is '${field.value}', which the engine reads as false\n` + + 'note: it was imported as written, and the printed script draws an error at the field\n' + + "note: 'ShellActivityBehavior.readFields' compares it " + + "with 'true' case-sensitively", }); } } @@ -5469,8 +5879,8 @@ function refuseBuiltinShapes( for (const group of BUILTIN_REQUIRED_FIELDS[type]) { if (group.names.some((name) => names.includes(name))) continue; throw refusal( - `${named} without a ${formatWordList(group.names)} field, which Operaton refuses to deploy: ` + - `"${group.error}" (${method})`, + `${named} without a ${formatWordList(group.names)} field`, + `note: Operaton refuses to deploy it with "${group.error}" (${method})`, ); } @@ -5479,9 +5889,9 @@ function refuseBuiltinShapes( ); if (unknown !== undefined) { throw refusal( - `${named} with a field '${unknown}', which the ${type} behaviour does ` + - 'not declare; Operaton refuses to deploy it: "Field definition uses ' + - `unexisting field '${unknown}'" (ClassDelegateUtil.applyFieldDeclaration)`, + `${named} with a field '${unknown}', which the ${type} behaviour does not declare`, + 'note: Operaton refuses to deploy it with "Field definition uses ' + + `unexisting field '${unknown}'" ('ClassDelegateUtil.applyFieldDeclaration')`, ); } } @@ -5493,19 +5903,17 @@ function withExternalExtras( warnings: ImportWarning[], ): ServiceTaskBinding { const taskPriority = readNamespacedAttr(el, 'taskPriority'); - const definitions = extensionValues(el).filter( - (value) => value.$type === 'operaton:ErrorEventDefinition', - ); + const definitions = extensionsOf(el, 'operaton:ErrorEventDefinition'); if (binding.kind !== 'external') { const present = [ - ...(taskPriority === undefined ? [] : ["'taskPriority' setting"]), - ...extensionValues(el) - .filter((value) => value.$type === 'operaton:Properties') - .map(() => 'operaton:properties block'), + ...(taskPriority === undefined ? [] : ["setting 'taskPriority'"]), + ...extensionsOf(el, 'operaton:Properties').map( + () => "'operaton:properties' block", + ), ...definitions.map( (defEl) => - 'operaton:errorEventDefinition' + + "'operaton:errorEventDefinition'" + (defEl.id === undefined ? '' : ` '${defEl.id}'`), ), ]; @@ -5514,10 +5922,10 @@ function withExternalExtras( elementId: id, category: 'extensionAttribute', message: - `The ${what} on '${id}' was not imported: Operaton reads it in ` + - 'parseExternalServiceTask alone, which only ' + - 'operaton:type="external" reaches, so the step runs as written ' + - 'without it.', + `${what} on '${id}' was not imported\n` + + "note: Operaton reads it in 'parseExternalServiceTask' alone, which only " + + `'operaton:type="external"' reaches\n` + + 'note: the step runs as written without it', }); } return binding; @@ -5536,9 +5944,7 @@ function withExternalExtras( propertiesEl, 'name', `'${id}'`, - (message, category = 'extensionAttribute') => { - warnings.push({ elementId: id, category, message }); - }, + reporter(warnings, id), ); const errorMappings = definitions.flatMap( (defEl) => readErrorMapping(defEl, id, warnings) ?? [], @@ -5546,7 +5952,7 @@ function withExternalExtras( requireIntegerOrExpression(taskPriority, id, 'taskPriority'); return { ...binding, - ...(taskPriority === undefined ? {} : { taskPriority }), + ...defined({ taskPriority }), ...(properties.length === 0 ? {} : { properties }), ...(errorMappings.length === 0 ? {} : { errorMappings }), }; @@ -5569,10 +5975,9 @@ function readErrorMapping( elementId: ownerId, category: 'unmappedConstruct', message: - `The operaton:errorEventDefinition on '${ownerId}' carries no ` + - 'errorRef and was not imported: ' + - 'BpmnParse.parseOperatonErrorEventDefinitions skips one without ' + - 'it, so the document written back runs the same.', + `'operaton:errorEventDefinition' on '${ownerId}' was not imported\n` + + "note: it carries no 'errorRef', and 'BpmnParse.parseOperatonErrorEventDefinitions' skips one without it\n" + + 'note: the rebuilt document runs the same', }); return undefined; } @@ -5580,19 +5985,22 @@ function readErrorMapping( noteRewrappedExpression( condition, ownerId, - 'operaton:errorEventDefinition expression', + "'operaton:errorEventDefinition' expression", warnings, ); if (condition === undefined) { throw new UnsupportedErrorMappingError( ownerId, - 'the operaton:errorEventDefinition carries no expression', + "the 'operaton:errorEventDefinition' carries no expression", + "note: Operaton's 'parseOperatonErrorEventDefinitions' fails the " + + "deployment on an 'operaton:errorEventDefinition' that carries no expression", ); } if (errorCode === undefined) { throw new UnsupportedErrorMappingError( ownerId, - `its errorRef names the error root '${ref?.id}', which carries no code`, + `its 'errorRef' names the error root '${ref?.id}', which carries no code`, + "note: BPMNscript needs its 'errorRef' to name an error root that carries a code", ); } return { errorCode, condition }; @@ -5619,7 +6027,7 @@ function readCodeOrExternalBinding( warnShadowedImplementation( el, id, - `operaton:${winner}`, + `'operaton:${winner}'`, consumed, warnings, ); @@ -5670,7 +6078,7 @@ function readCodeOrExternalBinding( function readDecisionBinding( el: ModdleElement, id: string, - refusal: (detail: string) => Error, + refusal: Refusal, warnings: ImportWarning[], ): Extract | undefined { const decisionRef = readNamespacedAttr(el, 'decisionRef'); @@ -5684,19 +6092,21 @@ function readDecisionBinding( ); return undefined; } - warnShadowedImplementation(el, id, 'an operaton:decisionRef', [], warnings); + warnShadowedImplementation(el, id, "an 'operaton:decisionRef'", [], warnings); const tenantId = readNamespacedAttr(el, 'decisionRefTenantId'); if (tenantId !== undefined) { - throw refusal(tenantPinDetail('decisionRefTenantId', tenantId, 'decision')); + throw refusal( + ...tenantPinDetail('decisionRefTenantId', tenantId, 'decision'), + ); } const mapping = readNamespacedAttr(el, 'mapDecisionResult'); const mapDecisionResult = DECISION_RESULT_MAPPINGS.find((m) => m === mapping); if (mapping !== undefined && mapDecisionResult === undefined) { throw refusal( - `operaton:mapDecisionResult="${mapping}", which is not a way of ` + - 'filling the result variable this tool can represent', + `'operaton:mapDecisionResult="${mapping}"'`, + 'note: it is not a way of filling the result variable BPMNscript can represent', ); } @@ -5704,8 +6114,7 @@ function readDecisionBinding( return { kind: 'decision', decisionRef, - ...(binding === undefined ? {} : { binding }), - ...(mapDecisionResult === undefined ? {} : { mapDecisionResult }), + ...defined({ binding, mapDecisionResult }), }; } @@ -5722,8 +6131,8 @@ function warnDanglingModifiers( elementId: id, category: 'extensionAttribute', message: - `The '${attr}' setting on '${id}' has no effect without an ` + - `${ref} and was not imported.`, + `setting '${attr}' on '${id}' was not imported\n` + + `note: it has no effect without an '${ref}'`, }); } } @@ -5738,8 +6147,8 @@ function buildShadowedImplementationWarning( elementId: id, category: 'extensionAttribute', message: - `The '${attr}' setting on ${subject} has no effect alongside ` + - `${winner} and was not imported.`, + `setting '${attr}' on ${subject} was not imported\n` + + `note: it has no effect alongside ${winner}`, }; } @@ -5784,31 +6193,29 @@ const CODE_ATTRS = [ function detectUnsupportedServiceTaskForm( el: ModdleElement, host: BindingHost, -): string { +): [construct: string, ...notes: string[]] { const type = readNamespacedAttr(el, 'type'); - if (type === undefined) return 'no execution discriminator'; + if (type === undefined) return ['no execution discriminator']; const named = isExternalType(type) - ? `operaton:type="${type}" without an operaton:topic` - : `operaton:type="${type}"`; + ? `'operaton:type="${type}"' without an 'operaton:topic'` + : `'operaton:type="${type}"'`; const shadowed = CODE_ATTRS.filter( (attr) => readNamespacedAttr(el, attr) !== undefined, - ).map((attr) => `operaton:${attr}`); - const clauses = [ + ).map((attr) => `'operaton:${attr}'`); + return [ + named, ...(host === 'thrownMessage' && builtinTypeOf(type) !== undefined ? [ - 'which this surface carries on a service, send or business rule task alone', + 'note: BPMNscript carries it on a service, send or business rule task alone', ] : []), ...(shadowed.length === 0 ? [] : [ - `which Operaton resolves ahead of the ${formatPlainWordList(shadowed, 'and')} alongside it`, + `note: Operaton resolves it ahead of the ${formatPlainWordList(shadowed, 'and')} alongside it`, ]), ]; - return clauses.length === 0 - ? named - : `${named}, ${formatPlainWordList(clauses, 'and')}`; } function mapScriptTask( @@ -5820,7 +6227,7 @@ function mapScriptTask( if (resource !== undefined) { throw new UnsupportedExtensionFormError( id, - externalResourceDetail(`the script on '${id}'`, resource), + ...externalResourceDetail(`the script on '${id}'`, resource), ); } const named = readNamed(el, id, warnings); @@ -5829,16 +6236,16 @@ function mapScriptTask( if (rawFormat === '') { throw new UnsupportedExtensionFormError( id, - `the script on '${id}' has an empty scriptFormat; ` + - '`ScriptUtil.getScript` refuses to deploy it', + `the script on '${id}' has an empty 'scriptFormat'`, + "note: 'ScriptUtil.getScript' refuses to deploy it", ); } const body = el.get('script'); if (typeof body !== 'string') { throw new UnsupportedExtensionFormError( id, - `the script on '${id}' has neither a script body nor a resource; ` + - '`ScriptUtil.getScript` refuses to deploy it with neither', + `the script on '${id}' has neither a script body nor a resource`, + "note: 'ScriptUtil.getScript' refuses to deploy it with neither", ); } let format: string; @@ -5848,10 +6255,9 @@ function mapScriptTask( elementId: id, category: 'rewritten', message: - `The script on '${id}' has no scriptFormat; ` + - '`BpmnParse.parseScriptTaskElement` substitutes ' + - '`ScriptingEngines.DEFAULT_SCRIPTING_LANGUAGE` (juel), and it was ' + - 'imported as such.', + `script on '${id}' has no 'scriptFormat' and imports as 'juel'\n` + + "note: 'BpmnParse.parseScriptTaskElement' substitutes " + + "'ScriptingEngines.DEFAULT_SCRIPTING_LANGUAGE', which is 'juel'", }); } else { const canonical = scriptFormatOf(rawFormat); @@ -5861,15 +6267,14 @@ function mapScriptTask( elementId: id, category: 'carriedAsWritten', message: - `The script on '${id}' names the language '${rawFormat}', which ` + - 'the DSL has no fence alias for; it was imported as written, and ' + - 'the printed script draws an error there.', + `script on '${id}' names the language '${rawFormat}', which BPMNscript has no fence alias for\n` + + 'note: it was imported as written, and the printed script draws an error there', }); } else { format = canonical; } } - checkScriptBody(body, id, `the script on '${id}'`, warnings); + checkScriptBody(body, id, `the script on '${id}'`); const resultVariable = readResultVariable(el, id, warnings); return { kind: 'scriptTask', @@ -5877,42 +6282,28 @@ function mapScriptTask( ...named, format, code: body, - ...(resultVariable === undefined ? {} : { resultVariable }), + ...defined({ resultVariable }), ...readEngineAttributes(el, id, warnings), ...readIoMapping(el, id, warnings), }; } -function mapDefaultingGateway( +function mapGateway( el: ModdleElement, - kind: 'exclusiveGateway' | 'inclusiveGateway', + kind: 'exclusiveGateway' | 'inclusiveGateway' | 'parallelGateway', warnings: ImportWarning[], -): ExclusiveGateway | InclusiveGateway { +): ExclusiveGateway | InclusiveGateway | ParallelGateway { const id = requireId(el); refuseIoMapping(el, id, 'checkActivityInputOutputSupported'); const named = readNamed(el, id, warnings, readString(el, 'name')); - const defaultFlowId = getEl(el, 'default')?.id; + const defaultFlowId = + kind === 'parallelGateway' ? undefined : getEl(el, 'default')?.id; return { kind, id, ...named, - ...(defaultFlowId === undefined ? {} : { defaultFlowId }), - ...readJobSettings(el, id, warnings), - }; -} - -function mapParallelGateway( - el: ModdleElement, - warnings: ImportWarning[], -): ParallelGateway { - const id = requireId(el); - refuseIoMapping(el, id, 'checkActivityInputOutputSupported'); - const named = readNamed(el, id, warnings, readString(el, 'name')); - return { - kind: 'parallelGateway', - id, - ...named, + ...defined({ defaultFlowId }), ...readJobSettings(el, id, warnings), }; } @@ -5926,10 +6317,10 @@ function mapEventBasedGateway( if (readNamespacedFlag(el, 'asyncAfter') === true) { throw new UnsupportedEventFeatureError( id, - `'${id}' is marked asyncAfter, which BpmnParse.parseEventBasedGateway ` + - 'refuses to deploy on a wait with several branches', - `Drop the asyncAfter setting from '${id}'; its other job settings are ` + - 'read as written.', + `'${id}' is marked 'asyncAfter'`, + "note: 'BpmnParse.parseEventBasedGateway' refuses to deploy it on a wait with several branches", + `help: drop the 'asyncAfter' setting from '${id}'`, + 'note: its other job settings are read as written', ); } warnIgnoredWaitAttrs(el, id, warnings); @@ -5956,9 +6347,9 @@ function warnIgnoredWaitAttrs( elementId: id, category: 'unmappedConstruct', message: - `The '${attr}' attribute on '${id}' was not imported: Operaton does ` + - 'not read it on a wait with several branches, so the imported ' + - 'process runs exactly as the source document does.', + `attribute '${attr}' on '${id}' was not imported\n` + + 'note: Operaton does not read it on a wait with several branches\n' + + 'note: the imported process runs exactly as the original document does', }); }; @@ -6013,10 +6404,10 @@ function mapSequenceFlow( elementId: id, category: 'behaviourChanged', message: - `The condition on '${id}' has an empty body: ` + - 'UelExpressionCondition.evaluate reads it as a string and fails the ' + - 'flow on every run ("condition expression returns non-Boolean"); ' + - 'the flow was imported with no condition.', + `condition on '${id}' has an empty body and was dropped\n` + + "note: 'UelExpressionCondition.evaluate' reads it as a string and fails the " + + 'flow on every run ("condition expression returns non-Boolean")\n' + + 'note: the flow was imported with no condition', }); } else if ( conditionExpression !== undefined && @@ -6026,18 +6417,18 @@ function mapSequenceFlow( elementId: id, category: 'behaviourChanged', message: - `The condition on '${id}' is the bare text ` + - `${JSON.stringify(conditionExpression)} with no "\${...}" or ` + - '"#{...}" opener: UelExpressionCondition.evaluate reads it as a ' + + `condition on '${id}' is the bare text '${oneLine(conditionExpression)}' and is rewritten as an expression\n` + + "note: it has no '${...}' or '#{...}' opener\n" + + "note: '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.', + 'returns non-Boolean")\n' + + "note: the script writes it inside '${...}', which evaluates it", }); } noteRewrappedExpression( conditionExpression, id, - 'bpmn:conditionExpression', + "'bpmn:conditionExpression'", warnings, ); @@ -6053,19 +6444,26 @@ function mapSequenceFlow( elementId: id, category: 'label', message: - `The name ${JSON.stringify(name)} on the flow '${id}' was not ` + - 'imported: the script has no label for a flow, and the rebuilt ' + + `name '${oneLine(name)}' on the flow '${id}' was not imported\n` + + 'note: the script has no label for a flow\n' + (derived === undefined - ? 'document leaves this one unnamed.' - : `document names this one by its condition (${JSON.stringify(derived)}).`), + ? 'note: the rebuilt document leaves this flow unnamed' + : `note: the rebuilt document names this flow by its condition ('${oneLine(derived)}')`), }); } + const conditionDocumentation = + conditionExpression === undefined + ? undefined + : expressionEl !== undefined + ? readDocumentation(expressionEl, id, warnings) + : pickDocumentation(dropped!.documentation, id, warnings); + return { id, sourceRef, targetRef, - ...(conditionExpression === undefined ? {} : { conditionExpression }), + ...defined({ conditionExpression, conditionDocumentation }), }; } @@ -6075,8 +6473,8 @@ function conditionTypeRefusal( ): UnsupportedConditionExpressionError { return new UnsupportedConditionExpressionError( id, - `it is typed xsi:type="${xsiType}", which ` + - 'BpmnParse.parseConditionExpression fails the deployment on ' + + `it is typed 'xsi:type="${xsiType}"'`, + "note: 'BpmnParse.parseConditionExpression' fails the deployment on it " + '("Invalid type, only tFormalExpression is currently supported")', ); } @@ -6110,25 +6508,26 @@ function refuseOrWarnScriptedCondition( warnings: ImportWarning[], ): void { if (language !== undefined) { - throw new UnsupportedConditionExpressionError( - id, - resource === undefined - ? `it declares language="${language}", which Operaton runs in a ` + - 'script engine rather than evaluating as UEL' - : `it declares language="${language}" with operaton:resource=` + - `"${resource}", which Operaton runs as that deployed script ` + - 'rather than the body written here', - ); + throw resource === undefined + ? new UnsupportedConditionExpressionError( + id, + `it declares 'language="${language}"'`, + 'note: Operaton runs it in a script engine rather than evaluating it as UEL', + ) + : new UnsupportedConditionExpressionError( + id, + `it declares 'language="${language}"' with 'operaton:resource="${resource}"'`, + 'note: Operaton runs that deployed script rather than the body written here', + ); } if (resource !== undefined) { warnings.push({ elementId: id, category: 'extensionAttribute', message: - `The 'operaton:resource' setting on '${id}' 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.', + `setting 'operaton:resource' on '${id}' was not imported\n` + + "note: it only takes effect alongside a 'language' attribute\n" + + 'note: on its own the condition runs as the expression written in the body', }); } } @@ -6140,9 +6539,7 @@ function requireFlowEndpoint( ): string { const endpoint = el.get(property) as ModdleElement | undefined; if (endpoint === undefined || endpoint.id === undefined) { - throw new Error( - ` has no resolvable ${property}.`, - ); + throw new Error(`sequence flow '${id}' has no resolvable '${property}'`); } return endpoint.id; } @@ -6156,8 +6553,8 @@ function refuseMultipleEventDefinitions( if (defs.length > 1) { throw new UnsupportedEventFeatureError( id, - `${subject} carries ${defs.length} event definitions: only a single ` + - allowed, + `${subject} carries ${defs.length} event definitions`, + `note: only a single ${allowed}`, ); } } @@ -6168,7 +6565,7 @@ function eventDefinitionsOf(el: ModdleElement): ModdleElement[] { function requireId(el: ModdleElement): string { if (el.id === undefined || el.id === '') { - throw new Error(`<${el.$type}> is missing its required 'id' attribute.`); + throw new Error(`'${el.$type}' is missing its required 'id' attribute`); } return el.id; } @@ -6193,16 +6590,15 @@ function readText(el: ModdleElement, name: string): string | undefined { * Dropped when it equals the derived label, which keeps DSL -> XML -> DSL * idempotent. */ -function readDerivableName( - el: ModdleElement, - id: string, - derived: string = humanize(id), -): string | undefined { +function readDerivableName(el: ModdleElement, id: string): string | undefined { const name = readText(el, 'name'); - return name === undefined || name === derived ? undefined : name; + return name === humanize(id) ? undefined : name; } -/** A gateway passes `name` itself: the export derives no label from its id. */ +/** + * Gateways and start and end events pass `name` themselves: the export derives + * no label from their id. + */ function readNamed( el: ModdleElement, id: string, @@ -6211,56 +6607,95 @@ function readNamed( ): Named { const documentation = readDocumentation(el, id, warnings); return { - ...(name === undefined ? {} : { name }), - ...(documentation === undefined ? {} : { documentation }), + ...defined({ name, documentation }), }; } +const PLAIN_TEXT = 'text/plain'; + +interface DocumentationText { + text: string; + textFormat: string; +} + /** moddle answers `text/plain` for an absent `textFormat`. */ function readDocumentation( el: ModdleElement, id: string, warnings: ImportWarning[], ): string | undefined { - const children = documentationChildren(el); + return pickDocumentation( + documentationChildren(el).map((child) => { + const text = child.get('text'); + return { + text: typeof text === 'string' ? text : '', + textFormat: String(child.get('textFormat')), + }; + }), + id, + warnings, + ); +} + +function pickDocumentation( + children: readonly DocumentationText[], + id: string, + warnings: ImportWarning[], +): string | undefined { const [first] = children; if (first === undefined) return undefined; const report = (detail: string): undefined => { warnings.push({ elementId: id, category: 'documentation', - message: `The documentation on '${id}' was not imported: ${detail}.`, + message: `documentation on '${id}' was not imported\nnote: ${detail}`, }); return undefined; }; if (children.length > 1) { return report( - `this surface holds one and '${id}' carries ` + + `BPMNscript holds one 'bpmn:documentation' and '${id}' carries ` + String(children.length), ); } - const textFormat = first.get('textFormat'); - if (textFormat !== 'text/plain') { + if (first.textFormat !== PLAIN_TEXT) { return report( - 'this surface holds plain text and its textFormat is ' + - `'${String(textFormat)}'`, + "BPMNscript holds plain text and its 'textFormat' is " + + `'${first.textFormat}'`, ); } - const text = first.get('text'); - return typeof text === 'string' ? text : ''; + return first.text; } function documentationChildren(el: ModdleElement): ModdleElement[] { return el.get('documentation') as ModdleElement[]; } +/** A catch binding prints as a plain name, so another name would not parse back. */ +function readCatchVariable( + defEl: ModdleElement, + ownerId: string, + attr: string, + caught: 'code' | 'message', +): string | undefined { + const name = readNamespacedAttr(defEl, attr); + if (name !== undefined && !isPlainName(name)) { + throw new UnsupportedEventFeatureError( + ownerId, + `it stores the caught ${caught} in '${oneLine(name)}', which BPMNscript cannot write back unchanged`, + `note: 'operaton:${attr}' names a variable, which BPMNscript writes as a plain name, and this name is not one`, + PLAIN_NAME_NOTE, + ); + } + return name; +} + /** `get` falls back to `$attrs` for an undeclared property. */ function readNamespacedAttr( el: ModdleElement, localName: string, ): string | undefined { - const value = el.get(`operaton:${localName}`); - return typeof value === 'string' && value.length > 0 ? value : undefined; + return readString(el, `operaton:${localName}`); } function extensionValues(el: ModdleElement): ModdleElement[] { @@ -6270,6 +6705,10 @@ function extensionValues(el: ModdleElement): ModdleElement[] { return extensionElements.get('values') as ModdleElement[]; } +function extensionsOf(el: ModdleElement, type: string): ModdleElement[] { + return extensionValues(el).filter((value) => value.$type === type); +} + /** `parseInputOutput` refuses a second block. */ function onlyIoMapping( el: ModdleElement, @@ -6308,10 +6747,9 @@ function readRunSettings( export function asyncRespellingMessage(subject: string): string { return ( - `The operaton:async="true" on ${subject} imports as asyncBefore: true: ` + - 'BpmnParse.isAsyncBefore reads the two spellings as one, and this ' + - 'tool writes it back as operaton:asyncBefore, which the engine reads ' + - 'the same.' + `'operaton:async="true"' on ${subject} imports as 'asyncBefore: true'\n` + + "note: 'BpmnParse.isAsyncBefore' reads the two spellings as one\n" + + "note: BPMNscript writes it back as 'operaton:asyncBefore', which the engine reads the same" ); } @@ -6338,13 +6776,6 @@ function readAsyncBefore( return true; } -export function priorityRefusal(attr: string, value: string): string { - return ( - `operaton:${attr}="${value}", which BpmnParse.parsePriority refuses to ` + - `deploy ("Value '${value}' for attribute '${attr}' is not a valid number")` - ); -} - /** `parsePriority` refuses a non-integer constant. */ function requireIntegerOrExpression( value: string | undefined, @@ -6360,7 +6791,9 @@ function requireIntegerOrExpression( } throw new UnsupportedExtensionFormError( ownerId, - priorityRefusal(attr, value), + `'operaton:${attr}="${value}"' is not a whole number or an expression`, + "note: 'BpmnParse.parsePriority' refuses to deploy it " + + `("Value '${value}' for attribute '${attr}' is not a valid number")`, ); } @@ -6405,11 +6838,10 @@ function timerJobExclusive( elementId: ownerId, category: 'extensionAttribute', message: - `'${ownerId}' writes operaton:exclusive="${tagValue}" on the event, ` + - 'which governs its async continuation job, and ' + - `operaton:exclusive="${value}" on its timer definition, which ` + - 'governs the timer job; this tool keeps one value for both and ' + - "took the timer definition's.", + `'operaton:exclusive="${tagValue}"' on the event '${ownerId}' was dropped\n` + + 'note: it governs the async continuation job of the event\n' + + `note: 'operaton:exclusive="${value}"' on its timer definition governs the timer job\n` + + "note: BPMNscript keeps one value for both and took the timer definition's", }); } return value; @@ -6424,7 +6856,7 @@ function readEngineAttributes( const executionListeners = readExecutionListeners(el, ownerId, warnings); return { ...settings, - ...(executionListeners === undefined ? {} : { executionListeners }), + ...defined({ executionListeners }), }; } @@ -6468,6 +6900,12 @@ function readFormFields( type Report = (message: string, category?: ImportWarningCategory) => void; +function reporter(warnings: ImportWarning[], ownerId: string): Report { + return (message, category = 'extensionAttribute') => { + warnings.push({ elementId: ownerId, category, message }); + }; +} + interface FieldContext { fieldId: string; ownerId: string; @@ -6483,8 +6921,9 @@ function warnCarriedAsWritten( reason: string, ): void { report( - `The ${what} on ${subject} was imported as written, and the printed ` + - `script draws an error at the field: ${reason}.`, + `the engine cannot use ${what} on ${subject} as written\n` + + `note: ${reason}\n` + + 'note: it was imported as written, and the printed script draws an error at the field', 'carriedAsWritten', ); } @@ -6498,13 +6937,12 @@ function refuseUnspellableVariable( what: string, ownerId: string, ): void { - if (isWritableName(name)) return; + if (isPlainName(name)) return; throw new UnsupportedExtensionFormError( ownerId, - `${what} '${name}' names a variable the script cannot spell (a name is ` + - "letters, digits and '_', with '-' between them, and no keyword), and " + - 'the engine sets the variable under that name, so writing another ' + - 'would change what runs', + `${what} '${name}' names a variable the script cannot spell`, + "note: a name is letters, digits and '_', with '-' between them, and no keyword", + 'note: the engine sets the variable under that name, so writing another would change what runs', ); } @@ -6514,16 +6952,14 @@ function readFormField( warnings: ImportWarning[], ): FormField { const fieldId = requireId(field); - refuseUnspellableVariable(fieldId, 'operaton:formField', ownerId); + refuseUnspellableVariable(fieldId, "'operaton:formField'", ownerId); const type = importFormFieldType(readString(field, 'type'), fieldId, ownerId); const ctx: FieldContext = { fieldId, ownerId, type, subject: `form field '${fieldId}' of '${ownerId}'`, - report: (message, category = 'extensionAttribute') => { - warnings.push({ elementId: ownerId, category, message }); - }, + report: reporter(warnings, ownerId), }; const label = readText(field, 'label'); @@ -6531,8 +6967,8 @@ function readFormField( let datePattern = readString(field, 'datePattern'); if (datePattern !== undefined && type !== 'date') { ctx.report( - `The 'datePattern' on ${ctx.subject} was not imported; ` + - 'FormTypes.parseFormPropertyType reads it on a date field alone.', + `'datePattern' on ${ctx.subject} was not imported\n` + + "note: 'FormTypes.parseFormPropertyType' reads it on a date field alone", ); datePattern = undefined; } @@ -6560,9 +6996,7 @@ function readFormField( return { id: fieldId, type, - ...(label === undefined ? {} : { label }), - ...(defaultValue === undefined ? {} : { defaultValue }), - ...(datePattern === undefined ? {} : { datePattern }), + ...defined({ label, defaultValue, datePattern }), ...(values.length === 0 ? {} : { values }), ...(constraints.length === 0 ? {} : { constraints }), ...(properties.length === 0 ? {} : { properties }), @@ -6581,26 +7015,26 @@ function literalDefaultReason( case 'number': return FORM_BOUND_TEXT.test(value) ? undefined - : 'LongFormType.convertValue parses it with Long.valueOf on every ' + + : "'LongFormType.convertValue' parses it with 'Long.valueOf' on every " + 'render of the form, and it is not an integer'; case 'boolean': { const lower = value.toLowerCase(); return lower === 'true' || lower === 'false' ? undefined - : 'BooleanFormType.convertValue reads it through Boolean.valueOf on ' + + : "'BooleanFormType.convertValue' reads it through 'Boolean.valueOf' on " + 'every render of the form'; } case 'date': return datePattern === undefined && ISO_DATE_ONLY_TEXT.test(value) - ? 'DateFormType parses it on every render of the form under the ' + - 'engine\'s own "dd/MM/yyyy" (ProcessEngineConfigurationImpl.' + - 'initFormTypes), since the field names no pattern, and an ISO ' + + ? "'DateFormType' parses it on every render of the form under the " + + "engine's own 'dd/MM/yyyy' ('ProcessEngineConfigurationImpl." + + "initFormTypes'), since the field names no pattern, and an ISO " + 'date does not fit it' : undefined; case 'enum': return values.some((entry) => entry.id === value) ? undefined - : "it names none of the field's values, and EnumFormType.validateValue " + + : "it names none of the field's values, and 'EnumFormType.validateValue' " + 'refuses it on every render of the form'; case 'string': return undefined; @@ -6624,9 +7058,9 @@ function readEnumValues( if (type !== 'enum') { const n = valueEls.length; report( - `The ${n} operaton:value ${n === 1 ? 'child' : 'children'} of ` + - `${subject} ${n === 1 ? 'was' : 'were'} not imported; ` + - 'FormTypes.parseFormPropertyType reads them on an enum field alone.', + `${n} 'operaton:value' ${n === 1 ? 'child' : 'children'} of ` + + `${subject} ${n === 1 ? 'was' : 'were'} not imported\n` + + "note: 'FormTypes.parseFormPropertyType' reads them on an enum field alone", ); return []; } @@ -6635,20 +7069,20 @@ function readEnumValues( const id = readString(valueEl, 'id'); if (id === undefined) { report( - `The operaton:value #${i + 1} of ${subject} has no id and was not imported.`, + `'operaton:value' #${i + 1} of ${subject} has no id and was not imported`, ); return; } if (byId.has(id)) { report( - `The operaton:value '${id}' of ${subject} is written twice and was ` + - 'imported once, at its first position with its last name, as ' + - 'FormTypes.parseFormPropertyType keeps it (LinkedHashMap.put).', + `'operaton:value' '${id}' of ${subject} is written twice and was imported once\n` + + 'note: it keeps its first position and its last name, as ' + + "'FormTypes.parseFormPropertyType' keeps it ('LinkedHashMap.put')", 'rewritten', ); } const label = readString(valueEl, 'name'); - byId.set(id, { id, ...(label === undefined ? {} : { label }) }); + byId.set(id, { id, ...defined({ label }) }); }); return [...byId.values()]; } @@ -6680,29 +7114,38 @@ function readConstraints( for (const constraintEl of constraintEls) { const name = readString(constraintEl, 'name'); const config = readString(constraintEl, 'config'); - const refuse = (detail: string): UnsupportedFormFieldConstraintError => + const refuse = ( + detail: string, + ...notes: string[] + ): UnsupportedFormFieldConstraintError => new UnsupportedFormFieldConstraintError( ownerId, fieldId, name ?? '(none)', detail, + ...notes, ); - if (name === undefined) throw refuse('it has no name'); + if (name === undefined) { + throw refuse('it has no name', ...FORM_CONSTRAINT_NAME_LINES); + } if (!isFormConstraintName(name)) { - throw refuse('no validator is registered under that name'); + throw refuse( + 'no validator is registered under that name', + ...FORM_CONSTRAINT_NAME_LINES, + ); } if (read.some((constraint) => constraint.name === name)) { throw refuse( - 'DefaultFormHandler.parseValidation deploys both, and this script ' + - 'holds each constraint once per field', + 'the field carries it twice', + "note: 'DefaultFormHandler.parseValidation' deploys both, and BPMNscript holds each constraint once per field", ); } const flag = FLAG_VALIDATORS[name]; if (flag !== undefined) { if (config !== undefined) { report( - `The config '${config}' on the '${name}' constraint of ${subject} ` + - `was not imported; ${flag} never reads it.`, + `config '${config}' on the '${name}' constraint of ${subject} was not imported\n` + + `note: '${flag}' never reads it`, ); } read.push({ name }); @@ -6711,10 +7154,11 @@ function readConstraints( const bound = BOUND_VALIDATORS[name]; if (config === undefined) { throw refuse( + 'it has no config', bound === undefined - ? 'FormValidators.createValidator needs a class name or an ' + + ? "note: 'FormValidators.createValidator' needs a class name or an " + 'expression in its config' - : `${bound} parses its config on every submission`, + : `note: '${bound}' parses its config on every submission`, ); } if (bound !== undefined) { @@ -6725,14 +7169,14 @@ function readConstraints( `'${name}' constraint`, `'${name}' fits a ${formatPlainWordList(fits)} field alone, and ` + `'${fieldId}' is a ${type} field, whose every submitted value ` + - `${bound} refuses`, + `'${bound}' refuses`, ); } if (!FORM_BOUND_TEXT.test(config)) { warnCarriedAsWritten( ctx, `'${name}' constraint`, - `${bound} parses its config as an integer on every submission, ` + + `'${bound}' parses its config as an integer on every submission, ` + `and '${config}' is not one`, ); } @@ -6747,8 +7191,8 @@ function readConstraints( * task's by `name` into a `HashMap`, which keeps no position. */ const PROPERTY_REWRITE_BY_KEY = { - id: ', at its first position with its last value, as DefaultFormHandler.parseProperties keeps it (LinkedHashMap.put)', - name: ' with its last value, as BpmnParseUtil.parseOperatonExtensionProperties keeps it (HashMap.put), at its first position', + id: "it keeps its first position and its last value, as 'DefaultFormHandler.parseProperties' keeps it ('LinkedHashMap.put')", + name: "it keeps its last value, as 'BpmnParseUtil.parseOperatonExtensionProperties' keeps it ('HashMap.put'), at its first position", } as const; function readPropertyEntries( @@ -6766,16 +7210,16 @@ function readPropertyEntries( const value = typeof raw === 'string' ? raw : undefined; if (key === undefined || value === undefined) { report( - `The operaton:property ${key === undefined ? `#${i + 1}` : `'${key}'`} ` + - `of ${subject} has no ${key === undefined ? keyAttr : 'value'} and ` + - 'was not imported.', + `'operaton:property' ${key === undefined ? `#${i + 1}` : `'${key}'`} ` + + `of ${subject} has no '${key === undefined ? keyAttr : 'value'}' and ` + + 'was not imported', ); return; } if (byKey.has(key)) { report( - `The operaton:property '${key}' of ${subject} is written twice and ` + - `was imported once${PROPERTY_REWRITE_BY_KEY[keyAttr]}.`, + `'operaton:property' '${key}' of ${subject} is written twice and was imported once\n` + + `note: ${PROPERTY_REWRITE_BY_KEY[keyAttr]}`, 'rewritten', ); } @@ -6811,15 +7255,15 @@ function onlyExtensionElement( reader: string, when = '', ): ModdleElement | undefined { - const matches = extensionValues(el).filter((value) => value.$type === type); + const matches = extensionsOf(el, type); if (matches.length > 1) { const tag = xmlTagOf(type); throw new UnsupportedExtensionFormError( ownerId, - `${matches.length} <${tag}> blocks, which Element.elementNS throws on ` + - `when ${reader} reads them${when} ("Parsing exception: multiple ` + - `elements with tag name '${localNameOf(tag)}' ` + - 'found"), and BpmnParse.execute lets that fail the deployment', + `${matches.length} '${tag}' blocks`, + `note: 'Element.elementNS' throws on them when '${reader}' reads them${when} ("Parsing exception: multiple ` + + `elements with tag name '${localNameOf(tag)}' found")`, + "note: 'BpmnParse.execute' lets that fail the deployment", ); } return matches[0]; @@ -6853,13 +7297,14 @@ function readIoParameters( if (name === undefined) { throw new UnsupportedExtensionFormError( ownerId, - `an ${tag} has no name, so there is nothing to bind its value to`, + `an '${tag}' has no name`, + 'note: without a name there is nothing to bind its value to', ); } - refuseUnspellableVariable(name, tag, ownerId); + refuseUnspellableVariable(name, `'${tag}'`, ownerId); return { name, - value: readParameterValue(param, ownerId, `${tag} '${name}'`, warnings), + value: readParameterValue(param, ownerId, `'${tag}' '${name}'`, warnings), }; }); const executeMethod = @@ -6869,10 +7314,12 @@ function readIoParameters( refuseRepeatedExtensionKey( read.map((param) => param.name), ownerId, - (name) => - `two ${tag} children share name="${name}"; Operaton runs both, the ` + - `last write winning (IoMapping.${executeMethod}), and this ` + - 'script binds each parameter name once per direction', + (name) => [ + `two '${tag}' children share 'name="${name}"'`, + 'note: Operaton runs both, the last write winning ' + + `('IoMapping.${executeMethod}')`, + 'note: BPMNscript binds each parameter name once per direction', + ], ); return read; } @@ -6894,7 +7341,8 @@ function readParameterValue( throw new UnsupportedExtensionFormError( ownerId, `${where} carries ${nested.length} nested values ` + - `(${nested.map((value) => value.$type).join(', ')}), and a value is one`, + `(${nested.map((value) => `'${value.$type}'`).join(', ')})`, + 'note: a value is one', ); } if (nested.length === 1) { @@ -6903,11 +7351,10 @@ function readParameterValue( elementId: ownerId, category: 'extensionAttribute', message: - `${where} carries both body text and a nested ` + - `<${nested[0].$type}> value: BpmnParseUtil` + - '.parseNestedParamValueProvider reads the nested value and never ' + - 'the text, and the document written back carries the nested ' + - 'value alone.', + `body text of ${where} was not imported\n` + + `note: it carries a nested '${nested[0].$type}' value beside it\n` + + "note: 'BpmnParseUtil.parseNestedParamValueProvider' reads the nested value and never the text\n" + + 'note: the rebuilt document carries the nested value alone', }); } return readNestedValue(nested[0], ownerId, where, 'value', warnings); @@ -6942,12 +7389,7 @@ function readNestedValue( ), }; case 'operaton:Script': - return readScriptValue( - def, - ownerId, - `the operaton:script in ${where}`, - warnings, - ); + return readScriptValue(def, ownerId, `the 'operaton:script' in ${where}`); case 'operaton:Value': if (position === 'item') { return { kind: 'text', text: readString(def, 'value') ?? '' }; @@ -6956,14 +7398,19 @@ function readNestedValue( default: break; } - throw new UnsupportedExtensionFormError( - ownerId, - position === 'item' - ? `an operaton:list in ${where} carries a <${def.$type}>; a list holds ` + - 'values, and an entry belongs in an operaton:map' - : `${where} carries a <${def.$type}> where a value belongs; an entry ` + - 'belongs in an operaton:map', - ); + const entryNote = "note: an entry belongs in an 'operaton:map'"; + throw position === 'item' + ? new UnsupportedExtensionFormError( + ownerId, + `an 'operaton:list' in ${where} carries a '${def.$type}'`, + 'note: a list holds values', + entryNote, + ) + : new UnsupportedExtensionFormError( + ownerId, + `${where} carries a '${def.$type}' where a value belongs`, + entryNote, + ); } function readMapEntry( @@ -6976,8 +7423,8 @@ function readMapEntry( if (key === undefined) { throw new UnsupportedExtensionFormError( ownerId, - `an operaton:entry in ${where} has no key, so there is nothing to look ` + - 'its value up by', + `an 'operaton:entry' in ${where} has no key`, + 'note: without a key there is nothing to look its value up by', ); } return { @@ -6985,7 +7432,7 @@ function readMapEntry( value: readParameterValue( entry, ownerId, - `operaton:entry '${key}' in ${where}`, + `'operaton:entry' '${key}' in ${where}`, warnings, ), }; @@ -6995,67 +7442,51 @@ function readMapEntry( * `ScriptUtil.getScript` runs the resource over an inline body, so the body * would never run. */ -function externalResourceDetail(where: string, resource: string): string { - return ( - `${where} names an external resource ("${resource}"); ` + - 'ScriptUtil.getScript runs the resource in place of the body, and only ' + - 'an inline body can be written here' - ); +function externalResourceDetail( + where: string, + resource: string, +): [detail: string, note: string] { + return [ + `${where} names an external resource ('${resource}')`, + "note: 'ScriptUtil.getScript' runs the resource in place of the body, " + + 'and only an inline body can be written here', + ]; } function readScriptValue( script: ModdleElement, ownerId: string, where: string, - warnings: ImportWarning[], ): ScriptValue { const resource = readString(script, 'resource'); if (resource !== undefined) { throw new UnsupportedExtensionFormError( ownerId, - externalResourceDetail(where, resource), + ...externalResourceDetail(where, resource), ); } const format = readString(script, 'scriptFormat'); if (format === undefined) { throw new UnsupportedExtensionFormError( ownerId, - `${where} has no scriptFormat, so there is no language to evaluate its ` + - 'body in', + `${where} has no 'scriptFormat'`, + 'note: without it there is no language to evaluate its body in', ); } const code = readString(script, 'value') ?? ''; - checkScriptBody(code, ownerId, where, warnings); + checkScriptBody(code, ownerId, where); return { kind: 'script', format, code }; } -/** - * The fence ends at the first three backticks. An empty body is carried: the - * engine checks for null only. - */ -function checkScriptBody( - code: string, - ownerId: string, - where: string, - warnings: ImportWarning[], -): void { +/** The fence ends at the first three backticks. */ +function checkScriptBody(code: string, ownerId: string, where: string): void { if (code.includes('```')) { throw new UnsupportedExtensionFormError( ownerId, - `${where} contains three consecutive backticks, which no script fence ` + - 'this language has can enclose', + `${where} contains three consecutive backticks`, + 'note: no script fence in BPMNscript can enclose them', ); } - if (code.trim() === '') { - warnings.push({ - elementId: ownerId, - category: 'carriedAsWritten', - message: - `The body of ${where} is empty: ScriptUtil.getScript deploys it, ` + - 'since it checks the source for null and not for emptiness, and ' + - 'the printed script draws an empty-body error there.', - }); - } } interface ListenerSpec { @@ -7075,9 +7506,7 @@ function readListeners( spec: ListenerSpec, extra: (listener: ModdleElement, event: E) => X, ): ({ event: E; binding: ListenerBinding } & X)[] | undefined { - const found = extensionValues(el).filter( - (value) => value.$type === spec.type, - ); + const found = extensionsOf(el, spec.type); if (found.length === 0) return undefined; return found.map((listener) => { @@ -7096,7 +7525,7 @@ function readExecutionListeners( el, { type: 'operaton:ExecutionListener', - tag: 'operaton:executionListener', + tag: "'operaton:executionListener'", events: EXECUTION_LISTENER_EVENTS, ownerId, warnings, @@ -7114,14 +7543,14 @@ function readTaskListeners( el, { type: 'operaton:TaskListener', - tag: 'operaton:taskListener', + tag: "'operaton:taskListener'", events: TASK_LISTENER_EVENTS, ownerId, warnings, }, (listener, event) => { const timer = readListenerTimer(listener, ownerId, event, warnings); - return timer === undefined ? {} : { timer }; + return defined({ timer }); }, ); } @@ -7134,15 +7563,17 @@ function readListenerEvent( if (event === undefined) { throw new UnsupportedExtensionFormError( spec.ownerId, - `an ${spec.tag} has no event, so there is no point in the lifecycle ` + - 'for it to fire at', + `an ${spec.tag} has no event`, + 'note: without an event there is no point in the lifecycle for it to fire at', ); } if (!(spec.events as readonly string[]).includes(event)) { throw new UnsupportedExtensionFormError( spec.ownerId, - `an ${spec.tag} has event="${event}", which is not one of ` + - spec.events.join(', '), + `an ${spec.tag} has an event its position does not accept ('event="${event}"')`, + `note: the events it accepts are ${formatPlainWordList( + spec.events.map((name) => `'${name}'`), + )}`, ); } return event as E; @@ -7175,10 +7606,10 @@ function buildEmptyTaskListenerBindingWarning( elementId: ownerId, category: 'carriedAsWritten', message: - `An ${tag} on '${ownerId}' has ${attr}="": ` + - 'BpmnParse.parseTaskListener checks no listener attribute for ' + - 'emptiness, so the task deploys and the listener fails when its ' + - 'event fires, and the document written back carries the empty text.', + `an ${tag} on '${ownerId}' has '${attr}=""' and fails when its event fires\n` + + "note: 'BpmnParse.parseTaskListener' checks no listener attribute for " + + 'emptiness, so the task deploys\n' + + 'note: the rebuilt document carries the empty text', }; } @@ -7207,8 +7638,8 @@ function resolveListenerBinding( if (present.length === 0) { throw new UnsupportedExtensionFormError( ownerId, - `an ${tag} carries no binding: one of class, expression, ` + - 'delegateExpression, or an operaton:script child is what it runs', + `an ${tag} carries no binding`, + "note: it runs one of 'class', 'expression', 'delegateExpression', or an 'operaton:script' child", ); } const [winner, ...losers] = present; @@ -7219,7 +7650,7 @@ function resolveListenerBinding( buildShadowedImplementationWarning( ownerId, loser, - winner, + `'${winner}'`, `an ${tag} on '${ownerId}'`, ), ); @@ -7231,8 +7662,9 @@ function resolveListenerBinding( if (isExecutionListener) { throw new UnsupportedExtensionFormError( ownerId, - `an ${tag} has class="", which BpmnParse.parseExecutionListener ` + - `refuses ("Attribute 'class' cannot be empty")`, + `an ${tag} has an empty class ('class=""')`, + "note: 'BpmnParse.parseExecutionListener' refuses it " + + `("Attribute 'class' cannot be empty")`, ); } warnings.push( @@ -7248,10 +7680,10 @@ function resolveListenerBinding( elementId: ownerId, category: 'carriedAsWritten', message: - `An ${tag} on '${ownerId}' has expression="": ` + - `${isExecutionListener ? 'ExpressionExecutionListener' : 'ExpressionTaskListener'} ` + - 'evaluates the empty text rather than refusing it, and the ' + - 'document written back carries it.', + `an ${tag} on '${ownerId}' has 'expression=""'\n` + + `note: '${isExecutionListener ? 'ExpressionExecutionListener' : 'ExpressionTaskListener'}' ` + + 'evaluates the empty text rather than refusing it\n' + + 'note: the rebuilt document carries it', }); } return { kind: 'expression', expression: expression! }; @@ -7260,8 +7692,8 @@ function resolveListenerBinding( if (isExecutionListener) { throw new UnsupportedExtensionFormError( ownerId, - `an ${tag} has delegateExpression="", which ` + - 'BpmnParse.parseExecutionListener refuses ("Attribute ' + + `an ${tag} has an empty delegate expression ('delegateExpression=""')`, + "note: 'BpmnParse.parseExecutionListener' refuses it (\"Attribute " + "'delegateExpression' cannot be empty\")", ); } @@ -7278,8 +7710,7 @@ function resolveListenerBinding( return readScriptValue( script!, ownerId, - `the operaton:script in an ${tag}`, - warnings, + `the 'operaton:script' in an ${tag}`, ); } } @@ -7298,10 +7729,10 @@ function readListenerTimer( elementId: ownerId, category: 'extensionAttribute', message: - `The ${defs[0].$type} on an operaton:taskListener with ` + - `event="${event}" was not imported: BpmnParse.parseTaskListener ` + - 'reads no event definition off a listener that is not a timeout, ' + - 'and the document written back carries none.', + `'${defs[0].$type}' on an 'operaton:taskListener' with ` + + `'event="${event}"' was not imported\n` + + "note: 'BpmnParse.parseTaskListener' reads no event definition off a listener that is not a timeout\n" + + 'note: the rebuilt document carries none', }); } return undefined; @@ -7309,15 +7740,16 @@ function readListenerTimer( if (defs.length === 0) { throw new UnsupportedExtensionFormError( ownerId, - 'an operaton:taskListener with event="timeout" carries no ' + - 'bpmn:timerEventDefinition, so nothing would ever fire it', + `an 'operaton:taskListener' with 'event="timeout"' carries no 'bpmn:timerEventDefinition'`, + 'note: without one nothing would ever fire it', ); } if (defs.length > 1) { throw new UnsupportedExtensionFormError( ownerId, - `an operaton:taskListener with event="timeout" carries ${defs.length} ` + - 'bpmn:timerEventDefinition children, and a timeout has one due time', + `an 'operaton:taskListener' with 'event="timeout"' carries ${defs.length} ` + + "'bpmn:timerEventDefinition' children", + 'note: a timeout has one due time', ); } // A timeout listener has no job settings on the surface. @@ -7327,25 +7759,25 @@ function readListenerTimer( elementId: ownerId, category: 'extensionAttribute', message: - `The operaton:exclusive="${exclusive}" on the timer of an ` + - `operaton:taskListener with event="timeout" on '${ownerId}' was not ` + - 'imported: this tool has no setting for it there, though Operaton ' + - 'locks the timeout job by it (BpmnParse.parseTimeoutTaskListener ' + - 'through parseTimer), so the document written back runs without it.', + `'operaton:exclusive="${exclusive}"' on the timer of an ` + + `'operaton:taskListener' with 'event="timeout"' on '${ownerId}' was not imported\n` + + 'note: BPMNscript has no setting for it there\n' + + "note: Operaton locks the timeout job by it ('BpmnParse.parseTimeoutTaskListener' " + + "through 'parseTimer'), so the rebuilt document runs without it", }); } - return { kind: 'timer', ...readTimerDefinition(defs[0], ownerId) }; + return { kind: 'timer', ...readTimerDefinition(defs[0], ownerId, warnings) }; } function refuseRepeatedExtensionKey( keys: readonly string[], ownerId: string, - detail: (key: string) => string, + detail: (key: string) => [detail: string, ...notes: string[]], ): void { const seen = new Set(); for (const key of keys) { if (seen.has(key)) { - throw new UnsupportedExtensionFormError(ownerId, detail(key)); + throw new UnsupportedExtensionFormError(ownerId, ...detail(key)); } seen.add(key); } @@ -7357,19 +7789,16 @@ function refuseRepeatedExtensionKey( */ function fieldChildren(carrier: ModdleElement): ModdleElement[] { const declared = carrier.get('fields') as ModdleElement[] | undefined; - return ( - declared ?? - extensionValues(carrier).filter((value) => value.$type === 'operaton:Field') - ); + return declared ?? extensionsOf(carrier, 'operaton:Field'); } const FIELDLESS_BINDING: Readonly< Record<'expression' | 'external' | 'decision' | 'script', string> > = { - expression: 'operaton:expression', - external: 'operaton:type="external"', - decision: 'an operaton:decisionRef', - script: 'an operaton:script child', + expression: "'operaton:expression'", + external: `'operaton:type="external"'`, + decision: "an 'operaton:decisionRef'", + script: "an 'operaton:script' child", }; /** @@ -7387,12 +7816,13 @@ function withInjectedFields( if (children.length === 0) return binding; if (!carriesFields(binding)) { - const reason = + const reasons = [ '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 ' + - FIELDLESS_BINDING[binding.kind]; + 'or shell binding and into no other', + `this one is bound by ${FIELDLESS_BINDING[binding.kind]}`, + ]; for (const field of children) { - warnFieldDrop(field, ownerId, where, reason, warnings); + warnFieldDrop(field, ownerId, where, reasons, warnings); } return binding; } @@ -7413,22 +7843,21 @@ const RAW_TEMPLATE_OPEN = /^[$#]\{/; const FIELD_VALUE_SLOTS = [ { property: 'stringValue', - subject: 'a stringValue attribute', + subject: "a 'stringValue' attribute", + evaluated: false, + }, + { + property: 'string', + subject: "an 'operaton:string' child", evaluated: false, }, - { property: 'string', subject: 'an operaton:string child', evaluated: false }, { property: 'expression', - subject: 'an operaton:expression child', + subject: "an 'operaton:expression' child", evaluated: true, }, ] as const; -/** Whitespace kept visible: it is often why the value is reported. */ -function oneLine(text: string): string { - return text.replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t'); -} - function readField( field: ModdleElement, ownerId: string, @@ -7436,7 +7865,7 @@ function readField( warnings: ImportWarning[], ): FieldInjection | undefined { const drop = (reason: string): undefined => { - warnFieldDrop(field, ownerId, where, reason, warnings); + warnFieldDrop(field, ownerId, where, [reason], warnings); return undefined; }; @@ -7471,6 +7900,8 @@ function readField( (chosen === undefined ? 'no value' : literals.map((w) => w.slot.subject).join(' and ')), + 'note: an injected field names exactly one value slot, and Operaton ' + + 'refuses to deploy one that names none or both of its literal slots', ); } @@ -7480,7 +7911,7 @@ function readField( let writtenBack: string | undefined; if (chosen.slot.evaluated && !evaluatedBack) { writtenBack = - 'a stringValue attribute, and the engine would inject that text ' + + "a 'stringValue' attribute, and the engine would inject that text " + 'rather than evaluate it'; } else if (chosen.slot.evaluated && !RAW_TEMPLATE_OPEN.test(chosen.value)) { writtenBack = @@ -7488,7 +7919,7 @@ function readField( 'directly after its quote'; } else if (!chosen.slot.evaluated && evaluatedBack) { writtenBack = - 'an operaton:expression child, and the engine would evaluate it ' + + "an 'operaton:expression' child, and the engine would evaluate it " + 'rather than inject the text'; } if (writtenBack !== undefined) { @@ -7503,9 +7934,8 @@ function readField( elementId: ownerId, category: 'rewritten', message: - `The injected field '${name}' on ${where} writes its value in an ` + - 'operaton:string child, which this tool writes back as a stringValue ' + - 'attribute; the engine injects the same text either way.', + `injected field '${name}' on ${where} is rewritten from an 'operaton:string' child to a 'stringValue' attribute\n` + + 'note: the engine injects the same text either way', }); } @@ -7514,9 +7944,8 @@ function readField( elementId: ownerId, category: 'extensionAttribute', message: - `The 'operaton:expression' child of the injected field '${name}' on ` + - `${where} has no effect alongside ${chosen.slot.subject} and was not ` + - 'imported.', + `'operaton:expression' child of the injected field '${name}' on ${where} was not imported\n` + + `note: it has no effect alongside ${chosen.slot.subject}`, }); } @@ -7529,15 +7958,16 @@ function warnFieldDrop( field: ModdleElement, ownerId: string, where: string, - reason: string, + reasons: readonly string[], warnings: ImportWarning[], ): void { const name = readString(field, 'name'); warnings.push({ elementId: ownerId, category: 'extensionAttribute', - message: - `The injected field ${name === undefined ? '(unnamed)' : `'${name}'`} ` + - `on ${where} was not imported: ${reason}.`, + message: [ + `injected field ${name === undefined ? '(unnamed)' : `'${name}'`} on ${where} was not imported`, + ...reasons.map((reason) => `note: ${reason}`), + ].join('\n'), }); } diff --git a/packages/transform/test/ast-to-ir.test.ts b/packages/transform/test/ast-to-ir.test.ts index a80cd37..38af564 100644 --- a/packages/transform/test/ast-to-ir.test.ts +++ b/packages/transform/test/ast-to-ir.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { astToIr } from '../src/ast-to-ir.js'; +import { HISTORY_TIME_TO_LIVE } from '../src/ir-to-xml.js'; import { makeDefaultFlowId, makeEventSubProcessId, @@ -25,7 +26,7 @@ import { externalBinding, gateway, messageDef, - processIr, + processIr as bareProcessIr, scriptTask, scriptValue, signalDef, @@ -33,6 +34,12 @@ import { } from './helpers/ir-fixtures.js'; import { ir, parse } from './helpers/parse.js'; +// A script that sets no historyTimeToLive lowers to the exporter's default. +const processIr = (...args: Parameters): BpmnProcess => ({ + ...bareProcessIr(...args), + historyTimeToLive: HISTORY_TIME_TO_LIVE, +}); + // Round trips pin valid lowering; this file keeps synthesized ids, fallbacks on rejected source and silent normalizations. // An error and an escalation share one declaration namespace, so the @@ -325,7 +332,7 @@ describe('astToIr: normalizations that print back unchanged', () => { ); }); - it('declares each used code first, minting a writable name for a code no name could spell', async () => { + it('declares each used code first, naming an undeclared code by its own text', async () => { await expectLowering( [ [ @@ -339,7 +346,7 @@ describe('astToIr: normalizations that print back unchanged', () => { ], [ 'throw error("order.failed")', - { errorDecls: [{ name: 'order_failed', code: 'order.failed' }] }, + { errorDecls: [{ name: 'order.failed', code: 'order.failed' }] }, ], [ 'emit escalation(MR)', diff --git a/packages/transform/test/cfg-analysis.test.ts b/packages/transform/test/cfg-analysis.test.ts index fd03984..5049e6f 100644 --- a/packages/transform/test/cfg-analysis.test.ts +++ b/packages/transform/test/cfg-analysis.test.ts @@ -504,14 +504,16 @@ describe('cfg analysis', () => { it('answers every query for an id no element declares, rather than throwing', () => { const cfg = analyzeCfg(graph([start('s'), task('m'), end('e')], 's>m m>e')); - expect(cfg.outgoing('nope')).toEqual([]); - expect(cfg.incoming('nope')).toEqual([]); - expect(cfg.immediateDominator('nope')).toBeUndefined(); - expect(cfg.immediatePostDominator('nope')).toBeUndefined(); - expect(cfg.dominates('nope', 'm')).toBe(false); - expect(cfg.dominates('m', 'nope')).toBe(false); - expect(cfg.postDominates('nope', 'm')).toBe(false); - expect(cfg.postDominates('m', 'nope')).toBe(false); + expect([ + cfg.outgoing('nope'), + cfg.incoming('nope'), + cfg.immediateDominator('nope'), + cfg.immediatePostDominator('nope'), + cfg.dominates('nope', 'm'), + cfg.dominates('m', 'nope'), + cfg.postDominates('nope', 'm'), + cfg.postDominates('m', 'nope'), + ]).toEqual([[], [], undefined, undefined, false, false, false, false]); }); // A goto after a do-while lands on C, which the loop's own `if d` reaches, so both close one cycle with its test. @@ -540,30 +542,6 @@ describe('cfg analysis', () => { 'start>A A>splitC splitC>B? splitC>joinC B>splitD splitD>C? splitD>joinD C>joinD joinD>joinC joinC>loop loop>A? loop>guard guard>C? guard>guardJoin guardJoin>end', ); - it.each([ - [ - "a jump after a do-while lands on the same step its own nested if reaches: the jump closes the loop's exit route, not a nested test, and its head is that step, not the ifs' merge", - LIFDT_POST_C, - ['Flow_loop_A', 'Flow_loop_C'], - true, - ], - [ - "the same jump behind a guard still closes the outer loop's exit route: the guard's own test is a cycle of jumps too, not a nested do-while on the guard", - LIFDT_POST_C_EXIT, - ['Flow_loop_A', 'Flow_guard_C'], - true, - ], - ] as [ - title: string, - process: BpmnProcess, - backEdges: string[], - jumpCycleHeadC: boolean, - ][])('%s', (_title, process, backEdges, jumpCycleHeadC) => { - const cfg = analyzeCfg(process); - expect(cfg.backEdges().map((f) => f.id)).toEqual(backEdges); - expect(cfg.isJumpCycleHead('C')).toBe(jumpCycleHeadC); - }); - const JUMP_CYCLE_ELEMENTS = [ start('start'), gateway('split'), @@ -574,26 +552,12 @@ describe('cfg analysis', () => { gateway('ifJoin'), task('C'), ]; - it.each([ - ['listed in statement order', JUMP_CYCLE_ELEMENTS], - ['listed with the jumped-to step first', JUMP_CYCLE_ELEMENTS.toReversed()], - ])( - 'an if jumping into a cycle of plain flow, the flow before it falling in elsewhere, is no do-while test when %s', - (_title, elements) => { - const cfg = analyzeCfg( - graph( - elements, - 'start>split split>B? split>join join>A A>ifSplit ifSplit>B? ifSplit>ifJoin B>ifJoin ifJoin>C C>A', - ), - ); - expect(cfg.backEdges().map((f) => f.id)).toEqual(['Flow_C_A']); - expect(cfg.ownLoopTest('B')).toBeUndefined(); - }, - ); + const JUMP_CYCLE_EDGES = + 'start>split split>B? split>join join>A A>ifSplit ifSplit>B? ifSplit>ifJoin B>ifJoin ifJoin>C C>A'; - it("a conditioned goto into a do-while body is no natural entry, so the loop's own test keeps its single back edge", () => { - // Element and flow order of one shuffled import. - const elements = [ + // Element and flow order of one shuffled import. + const GOTO_INTO_DO_WHILE = graph( + [ gateway('Gateway_p_1_1_t_1_join'), task('B'), gateway('Gateway_p_0_join'), @@ -606,28 +570,20 @@ describe('cfg analysis', () => { task('A'), gateway('Gateway_p_1_1_t_1_split'), start('StartEvent_p'), - ]; - const process = graph( - elements, - 'Gateway_p_0_split>B? B>Gateway_p_1_1_t_1_split ' + - 'Gateway_p_1_1_split>Gateway_p_1_1_join ' + - 'Gateway_p_1_1_t_1_join>Gateway_p_1_1_join ' + - 'Gateway_p_1_1_t_1_split>Gateway_p_1_1_t_1_join ' + - 'Gateway_p_1_loop>EndEvent_p Gateway_p_1_loop>A? ' + - 'C>Gateway_p_1_1_t_1_join Gateway_p_1_1_t_1_split>C? ' + - 'Gateway_p_0_join>A StartEvent_p>Gateway_p_0_split ' + - 'A>Gateway_p_1_1_split Gateway_p_1_1_join>Gateway_p_1_loop ' + - 'Gateway_p_1_1_split>B? Gateway_p_0_split>Gateway_p_0_join', - ); - expect( - analyzeCfg(process) - .backEdges() - .map((f) => f.id), - ).toEqual(['Flow_Gateway_p_1_loop_A']); - }); + ], + 'Gateway_p_0_split>B? B>Gateway_p_1_1_t_1_split ' + + 'Gateway_p_1_1_split>Gateway_p_1_1_join ' + + 'Gateway_p_1_1_t_1_join>Gateway_p_1_1_join ' + + 'Gateway_p_1_1_t_1_split>Gateway_p_1_1_t_1_join ' + + 'Gateway_p_1_loop>EndEvent_p Gateway_p_1_loop>A? ' + + 'C>Gateway_p_1_1_t_1_join Gateway_p_1_1_t_1_split>C? ' + + 'Gateway_p_0_join>A StartEvent_p>Gateway_p_0_split ' + + 'A>Gateway_p_1_1_split Gateway_p_1_1_join>Gateway_p_1_loop ' + + 'Gateway_p_1_1_split>B? Gateway_p_0_split>Gateway_p_0_join', + ); - it("hasNaturalEntry counts only a flow from outside the cycle: in the element order below, the inner loop's own unconditioned back edge (A>B) would otherwise pass for the outer loop's outside entry and steal its back edge", () => { - const elements = [ + const NESTED_LOOPS = graph( + [ gateway('Gateway_p_1_loop'), task('B'), gateway('Gateway_p_1_1_loop'), @@ -636,22 +592,81 @@ describe('cfg analysis', () => { task('A'), task('C'), start('StartEvent_p'), - ]; - const process = graph( - elements, - 'Gateway_p_1_loop>A? Gateway_p_1_1_loop>Gateway_p_1_loop ' + - 'Gateway_p_1_1_loop>B? B>C Gateway_p_0_join>A ' + - 'C>Gateway_p_1_1_loop Gateway_p_0_split>Gateway_p_0_join A>B ' + - 'Gateway_p_1_loop>C Gateway_p_0_split>B? StartEvent_p>Gateway_p_0_split', - ); - expect( - analyzeCfg(process) - .backEdges() - .map((f) => f.id), - ).toEqual([ - 'Flow_Gateway_p_1_loop_A', - 'Flow_Gateway_p_1_1_loop_B', - 'Flow_Gateway_p_1_loop_C', - ]); + ], + 'Gateway_p_1_loop>A? Gateway_p_1_1_loop>Gateway_p_1_loop ' + + 'Gateway_p_1_1_loop>B? B>C Gateway_p_0_join>A ' + + 'C>Gateway_p_1_1_loop Gateway_p_0_split>Gateway_p_0_join A>B ' + + 'Gateway_p_1_loop>C Gateway_p_0_split>B? StartEvent_p>Gateway_p_0_split', + ); + + it.each< + [ + title: string, + process: BpmnProcess, + backEdges: string[], + jumpCycleHeads: string[], + ownLoopTests: [step: string, test: string][], + ] + >([ + [ + "a jump after a do-while lands on the same step its own nested if reaches: the jump closes the loop's exit route, not a nested test, and its head is that step, not the ifs' merge", + LIFDT_POST_C, + ['Flow_loop_A', 'Flow_loop_C'], + ['C'], + [['A', 'loop']], + ], + [ + "the same jump behind a guard still closes the outer loop's exit route: the guard's own test is a cycle of jumps too, not a nested do-while on the guard", + LIFDT_POST_C_EXIT, + ['Flow_loop_A', 'Flow_guard_C'], + ['C'], + [['A', 'loop']], + ], + [ + 'an if jumping into a cycle of plain flow, the flow before it falling in elsewhere, is no do-while test when listed in statement order', + graph(JUMP_CYCLE_ELEMENTS, JUMP_CYCLE_EDGES), + ['Flow_C_A'], + [], + [], + ], + [ + 'the same if is no do-while test when listed with the jumped-to step first', + graph(JUMP_CYCLE_ELEMENTS.toReversed(), JUMP_CYCLE_EDGES), + ['Flow_C_A'], + [], + [], + ], + [ + "a conditioned goto into a do-while body is no natural entry, so the loop's own test keeps its single back edge", + GOTO_INTO_DO_WHILE, + ['Flow_Gateway_p_1_loop_A'], + [], + [['A', 'Gateway_p_1_loop']], + ], + [ + "hasNaturalEntry counts only a flow from outside the cycle: in the element order below, the inner loop's own unconditioned back edge (A>B) would otherwise pass for the outer loop's outside entry and steal its back edge", + NESTED_LOOPS, + [ + 'Flow_Gateway_p_1_loop_A', + 'Flow_Gateway_p_1_1_loop_B', + 'Flow_Gateway_p_1_loop_C', + ], + ['C'], + [ + ['B', 'Gateway_p_1_1_loop'], + ['A', 'Gateway_p_1_loop'], + ], + ], + ])('%s', (_title, process, backEdges, jumpCycleHeads, ownLoopTests) => { + const cfg = analyzeCfg(process); + const ids = process.flowElements.map((e) => e.id); + expect({ + backEdges: cfg.backEdges().map((f) => f.id), + jumpCycleHeads: ids.filter((id) => cfg.isJumpCycleHead(id)), + ownLoopTests: ids.flatMap((id) => { + const test = cfg.ownLoopTest(id); + return test === undefined ? [] : [[id, test]]; + }), + }).toEqual({ backEdges, jumpCycleHeads, ownLoopTests }); }); }); diff --git a/packages/transform/test/form-fields.test.ts b/packages/transform/test/form-fields.test.ts index a99526f..a134c18 100644 --- a/packages/transform/test/form-fields.test.ts +++ b/packages/transform/test/form-fields.test.ts @@ -75,12 +75,6 @@ describe('form fields', () => { } }); - it('are absent without a form block', async () => { - const process = await ir('process p { start S user U }'); - expect(startOf(process).formFields).toBeUndefined(); - expect(userOf(process).formFields).toBeUndefined(); - }); - it('refuse on import a type the script cannot express, naming the five it takes', async () => { const xml = await irToXml(await ir(SOURCE)); const err = await expectRefusal( @@ -88,7 +82,7 @@ describe('form fields', () => { UnsupportedFormFieldTypeError, ); expect(err.message).toMatch( - /'double'.*string, long, boolean, date, and enum/, + /'double'\nnote: .*'string', 'long', 'boolean', 'date', and 'enum'$/, ); }); }); diff --git a/packages/transform/test/helpers/bpmn-doc.ts b/packages/transform/test/helpers/bpmn-doc.ts index 17399d5..e4f06b9 100644 --- a/packages/transform/test/helpers/bpmn-doc.ts +++ b/packages/transform/test/helpers/bpmn-doc.ts @@ -53,7 +53,6 @@ const doc = (headText: string): XmlTag => export const bpmnDefs = wrap(HEAD_BPMN, DEFINITIONS_CLOSE); export const operatonDefs = wrap(HEAD_OPERATON, DEFINITIONS_CLOSE); -export const camundaDefs = wrap(HEAD_CAMUNDA, DEFINITIONS_CLOSE); export const dualDefs = wrap(HEAD_DUAL, DEFINITIONS_CLOSE); export const bpmnDoc = doc(HEAD_BPMN); diff --git a/packages/transform/test/helpers/expect-refusal.ts b/packages/transform/test/helpers/expect-refusal.ts index 5d72ed0..bcd2432 100644 --- a/packages/transform/test/helpers/expect-refusal.ts +++ b/packages/transform/test/helpers/expect-refusal.ts @@ -4,10 +4,12 @@ import { UnsupportedConstructError } from '../../src/errors.js'; /** * Asserts that an import was refused: `refusal` rejects with `errorClass`, and - * (when `detail` is given) the error's `detail` field names the offending - * construct. Returns the error so a caller can pin its remaining fields. + * (when `detail` is given) the error names the offending construct. Returns + * the error so a caller can pin its remaining fields. * - * A regular expression is matched with `toMatch`, a string with `toBe`. + * A string is the error's `detail` field, compared with `toBe`. A regular + * expression is matched against the whole message, whose note lines carry + * the engine's reasons. */ export const expectRefusal = async < E extends UnsupportedConstructError = UnsupportedConstructError, @@ -22,11 +24,10 @@ export const expectRefusal = async < ); expect(err).toBeInstanceOf(errorClass); if (detail !== undefined) { - const actual = (err as { detail: string }).detail; if (detail instanceof RegExp) { - expect(actual).toMatch(detail); + expect((err as Error).message).toMatch(detail); } else { - expect(actual).toBe(detail); + expect((err as { detail: string }).detail).toBe(detail); } } return err as E; diff --git a/packages/transform/test/helpers/ir-fixtures.ts b/packages/transform/test/helpers/ir-fixtures.ts index dd81131..d488119 100644 --- a/packages/transform/test/helpers/ir-fixtures.ts +++ b/packages/transform/test/helpers/ir-fixtures.ts @@ -1,3 +1,4 @@ +import { HISTORY_TIME_TO_LIVE } from '../../src/ir-to-xml.js'; import type { BuiltinTaskType } from '@bpmn-script/language'; import type { BoundaryEvent, @@ -298,43 +299,12 @@ export const eventHandler = ( { kind: 'endEvent', id: `${id}_End` }, ]); -interface EventSubProcessOptions { - id?: string; - isInterrupting?: false; -} - -// `Start -> End` over `SF_`. -export const eventSubProcess = ( - prefix: string, - eventDefinition: EventDefinition, - { id = `${prefix}Handler`, isInterrupting }: EventSubProcessOptions = {}, -): SubProcess => ({ - kind: 'subProcess', - id, - triggeredByEvent: true, - flowElements: [ - { - kind: 'startEvent', - id: `${prefix}Start`, - ...(isInterrupting === false ? { isInterrupting } : {}), - eventDefinition, - }, - { kind: 'endEvent', id: `${prefix}End` }, - ], - sequenceFlows: [ - { - id: `SF_${prefix}`, - sourceRef: `${prefix}Start`, - targetRef: `${prefix}End`, - }, - ], -}); - // The IR xmlToIr produces from tests/golden/invoice-approval-handwritten.bpmn. The process name is absent // because "Invoice Approval" equals humanize("invoice-approval"), which import drops as derivable. export const HANDWRITTEN_IMPORT_IR: BpmnProcess = { id: 'invoice-approval', isExecutable: true, + historyTimeToLive: HISTORY_TIME_TO_LIVE, flowElements: [ { kind: 'startEvent', id: 'ReviewStart' }, { diff --git a/packages/transform/test/ir-to-dsl.test.ts b/packages/transform/test/ir-to-dsl.test.ts index 8c6ba3f..f0c0611 100644 --- a/packages/transform/test/ir-to-dsl.test.ts +++ b/packages/transform/test/ir-to-dsl.test.ts @@ -47,6 +47,7 @@ import type { BpmnProcess, ExecutionListener, FieldInjection, + FlowContainer, FlowElement, FormField, JobSettings, @@ -114,9 +115,9 @@ async function reDesugar(dsl: string): Promise { // Errors the model itself draws; a fixture feeding one names it. const MODEL_REFUSAL = { - mintedId: 'is the id the compiler generates for the implicit', - orphanStep: 'This step can never run', - deadElse: 'could never run', + mintedId: 'is the id generated for the implicit', + orphanStep: 'this step can never run', + deadElse: "this 'else' branch can never run", } as const; // Re-parsing alone accepts a statement a printed jump cut off. @@ -498,9 +499,7 @@ describe('irToDsl: event layer', () => { `, ); }); -}); -describe('irToDsl: event layer (message / signal / timer / conditional)', () => { it('prints the implementation a thrown or emitted message carries', async () => { const thrown = minimalProcess( [ @@ -725,7 +724,7 @@ describe('irToDsl: synthesized terminal omission', () => { ); }); -describe('irToDsl: ids the script cannot spell print under a minted name', () => { +describe('irToDsl: ids the plain form cannot spell print in backticks', () => { const ir = processIr( 'WFP-6-', [ @@ -733,7 +732,7 @@ describe('irToDsl: ids the script cannot spell print under a minted name', () => user('Task.1'), user('user'), chainedSub('Sub.1', [start('S2'), task('Step.2'), end('E2')]), - typedEvent('intermediateThrowEvent', 'Notify.1', messageDef('M')), + typedEvent('intermediateThrowEvent', 'Notify.1', signalDef('M')), user('Review-'), end('E'), boundaryEvent('B', 'Task.1', timerDef('duration', 'PT1H')), @@ -744,39 +743,49 @@ describe('irToDsl: ids the script cannot spell print under a minted name', () => ], ); - it('prints every site under the minted name, reports each rename once, and the source compiles clean', async () => { + it('prints every site in backticks, reports nothing, and the source compiles back to the same ids', async () => { const { source, warnings } = printDsl(ir); expect(source).toBe( - `process WFP_6_ { + `process \`WFP-6-\` { start S - user Task_1 - user _user - subprocess Sub_1 { + user \`Task.1\` + user \`user\` + subprocess \`Sub.1\` { start S2 - step Step_2 + step \`Step.2\` end E2 } - emit message Notify_1("M") - user Review_ + emit signal \`Notify.1\`("M") + user \`Review-\` end E - on Task_1: timer("PT1H") { - goto Review_ + on \`Task.1\`: timer("PT1H") { + goto \`Review-\` } } `, ); - expectReports( - warnings, - ['renamedId', 'WFP-6-'], - ['renamedId', 'Task.1'], - ['renamedId', 'user'], - ['renamedId', 'Sub.1'], - ['renamedId', 'Step.2'], - ['renamedId', 'Notify.1'], - ['renamedId', 'Review-'], - ); + expect(warnings).toEqual([]); expect((await validate(source)).diagnostics).toEqual([]); - expect((await reDesugar(source)).id).toBe('WFP_6_'); + const rebuilt = await reDesugar(source); + const ids = (container: FlowContainer): string[] => + container.flowElements.flatMap((el) => [ + el.id, + ...('flowElements' in el ? ids(el) : []), + ]); + expect([rebuilt.id, ...ids(rebuilt)]).toEqual([ + 'WFP-6-', + 'S', + 'Task.1', + 'user', + 'Sub.1', + 'S2', + 'Step.2', + 'E2', + 'Notify.1', + 'Review-', + 'E', + 'Boundary_Task.1_timer', + ]); }); }); @@ -1057,7 +1066,7 @@ describe('irToDsl: a loop head whose exit is listed before its route into the bo ' } else if (n > 100) {', ' user T', ` ${marker}`, - ' }', + ' } else fail', ' user A', ' end EA', ], @@ -1074,7 +1083,8 @@ describe('irToDsl: a loop head whose exit is listed before its route into the bo ' end EA', ' } else if (n > 100) {', ' goto T', - ' }', + ' } else if (y > 1) {', + ' } else fail', ' user B', ' end EB', ' user T', @@ -1106,7 +1116,7 @@ describe('irToDsl: a loop head whose exit is listed before its route into the bo expect(bodyOf(source)).toBe( ['process p {', ' start S', ...body, '}', ''].join('\n'), ); - expectReports(warnings, ['inventedFallback', 'L'], ['droppedEdge', 'L']); + expectReports(warnings, ['droppedEdge', 'L']); const second = irToDsl(await reDesugar(await printed(ir))); const withoutMarker = bodyOf(source) .split('\n') @@ -1274,7 +1284,7 @@ describe('irToDsl: a split inside a loop body whose every route is conditioned', const loopHead = (declared: string): string => `process p {\n${declared} start S\n do {\n user Approve\n`; - const loopTail = ' }\n' + ' } while (clarified)\n'; + const loopTail = ' } else fail\n' + ' } while (clarified)\n'; const reviewBranch = ' } else if (!approved) {\n' + ' user Review\n'; it.each([ @@ -1328,45 +1338,11 @@ describe('irToDsl: a split inside a loop body whose every route is conditioned', ])('%s', async (_title, ir, expected) => { const { source, warnings } = printDsl(ir); expect(source).toBe(expected); - expectReports( - warnings, - ['inventedFallback', 'G1'], - ['droppedFlowCondition', 'G2'], - ); + expectReports(warnings, ['droppedFlowCondition', 'G2']); await printed(ir); }); }); -// A jump into one-way gateway `G` forwards through it to `R`. -const passThroughIr = ( - kind: 'exclusiveGateway' | 'inclusiveGateway' | 'eventBasedGateway', - id = 'G', -): BpmnProcess => - minimalProcess( - [start('S'), user('A'), { kind, id }, user('R'), end('E')], - [...chain('S', 'A', 'E'), edge('A', id), edge(id, 'R'), edge('R', 'E')], - ); - -const PASS_THROUGH_IR = passThroughIr('exclusiveGateway', 'Gateway_p_9_join'); - -// Hostile input: a back edge into a parallel fork, which no goto can name. -const GOTO_INTO_FORK_IR: BpmnProcess = minimalProcess( - [ - start('S'), - { kind: 'parallelGateway', id: 'Gateway_p_1_fork' }, - user('A'), - user('B'), - end('E'), - ], - [ - edge('S', 'Gateway_p_1_fork', { id: 'f0' }), - edge('Gateway_p_1_fork', 'A', { id: 'f1' }), - edge('Gateway_p_1_fork', 'B', { id: 'f2' }), - edge('A', 'E', { id: 'f3' }), - edge('B', 'Gateway_p_1_fork', { id: 'f4' }), - ], -); - function formBlockLines(dsl: string): string[] { const lines = dsl.split('\n'); const start = lines.findIndex((line) => line.trim() === 'form {'); @@ -2081,6 +2057,7 @@ describe('irToDsl: repeated activities', () => { ], [{ collection: 'lines' }, 'for each in lines'], [{ collection: 'check-close' }, 'for each in check-close'], + [{ collection: 'var' }, 'for each in "var"'], [ { collection: '${order.lines}', elementVariable: 'line' }, 'for each line in "${order.lines}"', @@ -2253,16 +2230,16 @@ const REPORT = { label: { category: 'label', says: ['block structure'] }, documentation: { category: 'documentation', - says: ['block structure', 'carry it'], - never: ['The label'], + says: ['block structure', 'carries it'], + never: ['label'], }, refusedStatement: { category: 'refusedStatement', - says: ['draws an error', 'Rename the step in the model'], + says: ['draws an error', 'rename the step in the model'], }, droppedEdge: { category: 'droppedEdge', - says: ['unstructured region', 'hand-repair'], + says: ['left out of the script', 'hand-repair'], }, degradedSplit: { category: 'degradedSplit', @@ -2280,29 +2257,25 @@ const REPORT = { crossBranchJump: { category: 'refusedStatement', says: ['crosses', 'draws an error'], - never: ['Rename the step'], - }, - inventedFallback: { - category: 'defaultFlow', - says: ['names no fallback', 'what runs changes'], + never: ['rename the step'], }, raceCondition: { category: 'droppedCondition', says: [ - 'weighs a branch of this wait', + 'on a branch of', 'takes the first to resolve', 'the run is the same without it', ], - never: ['stops the run with an error'], + never: ['stops the original process'], }, droppedFlowCondition: { category: 'droppedCondition', - says: ['stops the run with an error', 'carries straight on'], + says: ['stops the original process with an error', 'always continues'], }, divertedRun: { category: 'droppedCondition', - says: ['leaves by another route'], - never: ['stops the run with an error'], + says: ['takes the fallback'], + never: ['stops the original process'], }, unweighedBranch: { category: 'droppedCondition', @@ -2311,12 +2284,12 @@ const REPORT = { }, forkFallbackCondition: { category: 'defaultFlow', - says: ['weighs the fallback', 'the run is the same without it'], + says: ['on the fallback', 'the run is the same without it'], never: ['stops the run'], }, choiceFallbackCondition: { category: 'defaultFlow', - says: ['weighs the fallback', 'refuses to deploy'], + says: ['on the fallback', 'refuses to deploy'], never: ['the run is the same without it'], }, deadFallback: { @@ -2327,10 +2300,6 @@ const REPORT = { category: 'droppedSetting', says: ['engine settings', 'block structure', 'runs without them'], }, - renamedId: { - category: 'renamedId', - says: ['written as', 'activity id'], - }, } as const satisfies Record< string, { @@ -2340,7 +2309,7 @@ const REPORT = { } >; -const JARGON = ['flow node', 'gateway', 'token', 'sequence flow']; +const JARGON = ['flow node', 'token', 'sequence flow']; function expectReports( warnings: readonly PrintWarning[], @@ -2730,7 +2699,7 @@ describe('irToDsl: a jump into a branch of a fork or a race', () => { ' user A\n' + ' } else if (b) {\n' + ' user B\n' + - ' }\n' + + ' } else fail\n' + ' }\n' + ' {\n' + ' goto B\n' + @@ -2739,11 +2708,7 @@ describe('irToDsl: a jump into a branch of a fork or a race', () => { ' end E\n' + '}\n', ); - expectReports( - warnings, - ['inventedFallback', 'X'], - ['crossBranchJump', 'B'], - ); + expectReports(warnings, ['crossBranchJump', 'B']); const errors = (await validate(source)).diagnostics .filter((d) => d.severity === 1) .map((d) => @@ -3367,20 +3332,14 @@ describe('irToDsl: conditioned parallel branches', () => { [], ], [ - 'reports the fallback it had to invent when the model names none, which the source lowers to', + "closes a fork whose every branch is conditioned and whose model names no fallback with 'else fail', and reports nothing", inclusiveIr('all-conditioned'), block( ' var amount: any\n var urgent: any\n', - ' if (urgent) {\n user Record\n }\n', + ' if (urgent) {\n user Record\n }\n else fail\n', ), - [['inventedFallback', 'Gateway_p_1_fork']], - { - ...inclusiveIr('all-conditioned'), - sequenceFlows: [ - ...inclusiveIr('all-conditioned').sequenceFlows, - edge('Gateway_p_1_fork', 'Gateway_p_1_join'), - ], - }, + [], + inclusiveIr('all-conditioned'), [], ], [ @@ -3463,8 +3422,9 @@ describe('irToDsl: conditioned parallel branches', () => { }); describe('irToDsl: a split left with nowhere to go when no condition holds', () => { - // Operaton tries an exclusive split's routes in document order, so only the - // last one may print as the fall-through and give up its condition. + // Operaton stops the run there, so every route keeps its condition and the + // split closes with `else fail`; the route the block continues at prints as + // an empty branch. const invoiceIr = ( approval: [condition: string, target: string][], ): BpmnProcess => @@ -3499,7 +3459,7 @@ describe('irToDsl: a split left with nowhere to go when no condition holds', () ] >([ [ - 'overlapping conditions keep their order: the first keeps its condition and the last falls through', + 'overlapping conditions keep their order, and the last continues after the block behind an empty branch', minimalProcess( [start('S'), gateway('G'), user('A'), user('B'), end('EA'), end('EB')], [ @@ -3515,14 +3475,15 @@ describe('irToDsl: a split left with nowhere to go when no condition holds', () ' if (x > 1) {', ' user A', ' end EA', - ' }', + ' } else if (x > 5) {', + ' } else fail', ' user B', ' end EB', ], - [['inventedFallback', 'G']], + [], ], [ - 'an approval listed before its review falls through to the review', + 'an approval listed before its review continues at the review', invoiceIr([ ['${approved}', 'Pay'], ['${!approved}', 'Review'], @@ -3533,22 +3494,19 @@ describe('irToDsl: a split left with nowhere to go when no condition holds', () ' if (approved) {', ' user Pay', ' end E1', - ' }', + ' } else if (!approved) {', + ' } else fail', ' user Review', ' if (!clarified) {', ' end E2', ' } else if (clarified) {', - ' goto Approve', - ' }', - ' goto E1', - ], - [ - ['inventedFallback', 'G'], - ['inventedFallback', 'G2'], + ' } else fail', + ' goto Approve', ], + [], ], [ - 'an approval listed after its review falls through to the approval', + 'an approval listed after its review continues at the approval', invoiceIr([ ['${!approved}', 'Review'], ['${approved}', 'Pay'], @@ -3562,15 +3520,13 @@ describe('irToDsl: a split left with nowhere to go when no condition holds', () ' end E2', ' } else if (clarified) {', ' goto Approve', - ' }', - ' }', + ' } else fail', + ' } else if (approved) {', + ' } else fail', ' user Pay', ' end E1', ], - [ - ['inventedFallback', 'G'], - ['inventedFallback', 'G2'], - ], + [], ], [ 'a last route into a step reached from elsewhere keeps its condition behind an empty first branch', @@ -3602,13 +3558,13 @@ describe('irToDsl: a split left with nowhere to go when no condition holds', () ' if (a) {', ' } else if (b) {', ' goto B', - ' }', + ' } else fail', ' user A', ' end EA', ' user B', ' end EB', ], - [['inventedFallback', 'G']], + [], ], ])('%s', async (_, ir, body, reports) => { const { source, warnings } = printDsl(ir); @@ -3733,11 +3689,21 @@ describe('irToDsl: a split left with nowhere to go when no condition holds', () expect(source).toContain('while (again) {'); expectReports(warnings, ['degradedSplit', 'Review']); + expect(warnings[0]!.message.split('\n')[0]).toBe( + "element 'Review' was printed as jumps and its split was lost", + ); await printed(ir); }); - it('says nothing about an invented fallback at a fork that opens every branch', () => { - const { warnings } = printDsl( + it.each< + [ + title: string, + ir: BpmnProcess, + reports: (readonly [keyof typeof REPORT, string])[], + ] + >([ + [ + 'says nothing about an invented fallback at a fork that opens every branch', minimalProcess( [ start('S'), @@ -3755,24 +3721,19 @@ describe('irToDsl: a split left with nowhere to go when no condition holds', () ...chain('Record', 'Gateway_p_1_join', 'E'), ], ), - ); - - expectReports(warnings, ['unweighedBranch', 'Gateway_p_1_fork']); - }); - - it('reports the refusal alone when the one route the loop leaves is the weighed fallback itself', () => { - const { warnings } = printDsl( + [['unweighedBranch', 'Gateway_p_1_fork']], + ], + [ + 'reports the refusal alone when the one route the loop leaves is the weighed fallback itself', loopIntoSplitIr( gateway('Pick', 'Flow_settled'), {}, - { - id: 'Flow_settled', - condition: '${settled}', - }, + { id: 'Flow_settled', condition: '${settled}' }, ), - ); - - expectReports(warnings, ['choiceFallbackCondition', 'Pick']); + [['choiceFallbackCondition', 'Pick']], + ], + ])('%s', (_title, ir, reports) => { + expectReports(printDsl(ir).warnings, ...reports); }); }); @@ -3942,6 +3903,35 @@ describe('irToDsl: gateway settings', () => { flowChain('S', 'G', 'A', 'E'), ); + // A jump into the one-way merge forwards through it to `R`. + const PASS_THROUGH_IR: BpmnProcess = minimalProcess( + [start('S'), user('A'), gateway('Gateway_p_9_join'), user('R'), end('E')], + [ + ...chain('S', 'A', 'E'), + edge('A', 'Gateway_p_9_join'), + edge('Gateway_p_9_join', 'R'), + edge('R', 'E'), + ], + ); + + // Hostile input: a back edge into a parallel fork, which no goto can name. + const GOTO_INTO_FORK_IR: BpmnProcess = minimalProcess( + [ + start('S'), + { kind: 'parallelGateway', id: 'Gateway_p_1_fork' }, + user('A'), + user('B'), + end('E'), + ], + [ + edge('S', 'Gateway_p_1_fork', { id: 'f0' }), + edge('Gateway_p_1_fork', 'A', { id: 'f1' }), + edge('Gateway_p_1_fork', 'B', { id: 'f2' }), + edge('A', 'E', { id: 'f3' }), + edge('B', 'Gateway_p_1_fork', { id: 'f4' }), + ], + ); + it.each([ [ 'a one-way split the flow walks through', @@ -3950,13 +3940,10 @@ describe('irToDsl: gateway settings', () => { [['droppedSetting', 'G']], ], [ - 'a merge a jump forwards through', + 'a merge a branch runs through', PASS_THROUGH_IR, 'Gateway_p_9_join', - [ - ['degradedSplit', 'A'], - ['droppedSetting', 'Gateway_p_9_join'], - ], + [['droppedSetting', 'Gateway_p_9_join']], ], [ 'a fork degraded to jumps', @@ -3980,3 +3967,207 @@ describe('irToDsl: gateway settings', () => { }, ); }); + +describe('irToDsl: splits whose routes reconverge on a step that is already printed or ends a loop body', () => { + const route = (from: string, to: string, condition?: string): SequenceFlow => + edge(from, to, condition === undefined ? {} : { condition }); + // `W` loops through `B`, then a split reaches `W` again. + const loop = ( + flows: SequenceFlow[], + extra: Parameters[0], + ): BpmnProcess => + minimalProcess( + [start('S'), gateway('W'), user('B'), gateway('G'), user('X'), ...extra], + [ + route('S', 'W'), + route('W', 'B', '${c}'), + route('W', 'E1', '${!c}'), + route('B', 'G'), + ...flows, + ], + ); + + it.each< + [ + title: string, + ir: BpmnProcess, + body: string[], + reports: (readonly [keyof typeof REPORT, string])[], + ] + >([ + [ + 'a branch reaching a step an earlier sibling branch printed jumps to it instead of printing it again', + minimalProcess( + [ + start('S'), + typedEvent('startEvent', 'S2', messageDef('M2')), + gateway('G0'), + gateway('G'), + user('P'), + user('A'), + user('B'), + user('Xs'), + end('E'), + end('E3'), + ], + [ + route('S', 'G0'), + route('S2', 'Xs'), + route('G0', 'P', '${p}'), + route('G0', 'G', '${!p}'), + route('P', 'Xs'), + route('G', 'A', '${a}'), + route('G', 'B', '${b}'), + route('G', 'E3', '${c}'), + route('A', 'Xs'), + route('B', 'Xs'), + route('Xs', 'E'), + ], + ), + [ + ' start S', + ' if (p) {', + ' user P', + ' user Xs', + ' end E', + ' } else if (!p) {', + ' } else fail', + ' if (a) {', + ' user A', + ' goto Xs', + ' } else if (b) {', + ' user B', + ' goto Xs', + ' } else if (c) {', + ' } else fail', + ' end E3', + ' start S2 message("M2")', + ' goto Xs', + ], + [], + ], + [ + 'routes reaching the loop head from inside a branch of a body split end the body without a goto', + minimalProcess( + [ + start('S'), + gateway('W'), + gateway('G0'), + gateway('G'), + user('B'), + user('A'), + user('C'), + end('E'), + ], + [ + route('S', 'W'), + route('W', 'B', '${c}'), + route('W', 'E', '${!c}'), + route('B', 'G0'), + route('G0', 'G', '${x}'), + route('G0', 'A', '${y}'), + route('G0', 'C', '${z}'), + route('G', 'A', '${a}'), + route('G', 'C', '${b}'), + route('A', 'W'), + route('C', 'W'), + ], + ), + [ + ' start S', + ' while (c) {', + ' user B', + ' if (x) {', + ' if (a) {', + ' user A', + ' } else if (b) {', + ' user C', + ' } else fail', + ' } else if (y) {', + ' goto A', + ' } else if (z) {', + ' goto C', + ' } else fail', + ' }', + ' end E', + ], + [['droppedFlowCondition', 'W']], + ], + [ + 'a loop branch that ends prints its end inline instead of jumping out to it', + loop( + [ + route('G', 'X', '${a}'), + route('G', 'Y', '${b}'), + route('X', 'W'), + route('Y', 'E2'), + ], + [user('Y'), end('E1'), end('E2')], + ), + [ + ' start S', + ' while (c) {', + ' user B', + ' if (a) {', + ' user X', + ' } else if (b) {', + ' user Y', + ' end E2', + ' } else fail', + ' }', + ' end E1', + ], + [['droppedFlowCondition', 'W']], + ], + [ + // The rebuild puts a minted join between the test and `T`; the end must + // print in the branch both with and without it. + 'a loop test whose bare back edge comes first prints its conditioned end inside the branch, before the goto back', + minimalProcess( + [start('S'), user('T'), gateway('G'), end('E')], + [ + route('S', 'T'), + route('T', 'G'), + route('G', 'T'), + route('G', 'E', '${c}'), + ], + ), + [ + ' start S', + ' user T', + ' if (true) {', + ' } else if (c) {', + ' end E', + ' }', + ' goto T', + ], + [], + ], + [ + 'a loop test whose conditioned end comes before its bare back edge prints the end inside the branch, before the goto back', + minimalProcess( + [start('S'), user('T'), gateway('G'), end('E')], + [ + route('S', 'T'), + route('T', 'G'), + route('G', 'E', '${d}'), + route('G', 'T'), + ], + ), + [' start S', ' user T', ' if (d) {', ' end E', ' }', ' goto T'], + [], + ], + ])('%s', async (_, ir, body, reports) => { + const { source, warnings } = printDsl(ir); + expect(bodyOf(source)).toBe(['process p {', ...body, '}', ''].join('\n')); + expectReports(warnings, ...reports); + // The reported fallbacks add no-match routes; every route of the model stays. + const rebuilt = await reDesugar(await printed(ir)); + const reprinted = realReachability(rebuilt); + expect( + [...realReachability(ir)].filter((pair) => !reprinted.has(pair)), + ).toEqual([]); + // A reported fallback drops its condition's `var`, so the reprint compares bodies. + expect(bodyOf(irToDsl(rebuilt))).toBe(bodyOf(source)); + }); +}); diff --git a/packages/transform/test/ir-to-xml.test.ts b/packages/transform/test/ir-to-xml.test.ts index 3b55825..e39c887 100644 --- a/packages/transform/test/ir-to-xml.test.ts +++ b/packages/transform/test/ir-to-xml.test.ts @@ -148,6 +148,18 @@ describe('irToXml: document', () => { ]), 'inclusiveGateway "Fork" declares default flow "F_absent"', ], + [ + 'a default flow on a repeated step, which the engine can never take', + minimalProcess([ + { + kind: 'userTask', + id: 'T', + loop: { cardinality: '2' }, + defaultFlowId: 'F', + }, + ]), + 'userTask "T" is repeated and declares default flow "F", which the engine can never take.', + ], [ 'a boundary event whose host sits inside a sub-process', minimalProcess([ @@ -1028,31 +1040,16 @@ describe('irToXml: extension elements', () => { }), ); expect(xml.match(/\s*\s*\$\{response\}<\/operaton:outputParameter>\s*<\/operaton:inputOutput>\s*<\/bpmn:extensionElements>$/, - ); + expect(extensionBlock(xml)).toBe(` + + \${response} + + `); }); }); describe('irToXml: user task forms', () => { - it('writes a formRef with a latest binding as formRef and formRefBinding, no version', async () => { - const xml = await irToXml( - around({ - kind: 'userTask', - id: 'Task', - formRef: { key: 'review-form', binding: { kind: 'latest' } }, - }), - ); - expect( - pick(childById(await parseProc(xml), 'Task'), [ - 'formRef', - 'formRefBinding', - 'formRefVersion', - ]), - ).toEqual({ formRef: 'review-form', formRefBinding: 'latest' }); - }); - - it('serializes every extra a form field carries in descriptor order, datePattern and constraints in IR order, and no empty group for a field with none', async () => { + it('writes a latest formRef with no version, and every extra a form field carries in descriptor order, datePattern and constraints in IR order, no empty group for a field with none', async () => { const fields: FormField[] = [ { id: 'plan', @@ -1078,8 +1075,20 @@ describe('irToXml: user task forms', () => { { id: 'note', type: 'string' }, ]; const xml = await irToXml( - around({ kind: 'userTask', id: 'Task', formFields: fields }), + around({ + kind: 'userTask', + id: 'Task', + formRef: { key: 'review-form', binding: { kind: 'latest' } }, + formFields: fields, + }), ); + expect( + pick(childById(await parseProc(xml), 'Task'), [ + 'formRef', + 'formRefBinding', + 'formRefVersion', + ]), + ).toEqual({ formRef: 'review-form', formRefBinding: 'latest' }); expect(extensionBlock(xml)).toBe(` @@ -1456,10 +1465,6 @@ interface Moddle { extensionElements?: { values: Moddle[] }; incoming?: Moddle[]; outgoing?: Moddle[]; - sourceRef?: Moddle; - targetRef?: Moddle; - default?: Moddle; - attachedToRef?: Moddle; errorRef?: Moddle; escalationRef?: Moddle; messageRef?: Moddle; diff --git a/packages/transform/test/juel.test.ts b/packages/transform/test/juel.test.ts index 8135bf4..1e8535f 100644 --- a/packages/transform/test/juel.test.ts +++ b/packages/transform/test/juel.test.ts @@ -72,6 +72,8 @@ describe('parseJuel: raw classification keeps the verbatim body and its opener', ['${fn:size(list)}', 'fn:size(list)', '$'], ['${ns:fn(x, y)}', 'ns:fn(x, y)', '$'], ['#{myBean.check()}', 'myBean.check()', '#'], + ['${var == 1}', 'var == 1', '$'], + ['${order.end}', 'order.end', '$'], // A wrapperless body is tagged $ even when its text starts with #. ['#foo', '#foo', '$'], ])('%s', async (body, text, open) => { diff --git a/packages/transform/test/synthesize-ids.test.ts b/packages/transform/test/synthesize-ids.test.ts index ca821ab..2984e8f 100644 --- a/packages/transform/test/synthesize-ids.test.ts +++ b/packages/transform/test/synthesize-ids.test.ts @@ -2,10 +2,12 @@ import { describe, expect, it } from 'vitest'; import { isReservedName } from '@bpmn-script/language'; +import * as synthesizeIds from '../src/synthesize-ids.js'; import { + boundaryEventIdBase, + endIdOf, isMintedEndId, isMintedStartId, - isWritableName, makeGatewaySplitId, makeGatewayJoinId, makeGatewayForkId, @@ -19,81 +21,140 @@ import { makeEventSubProcessId, makeBoundaryEventId, makeIntermediateCatchEventId, - mintPrintableName, resolveCollision, } from '../src/synthesize-ids.js'; +import { irToXml } from '../src/ir-to-xml.js'; +import { chainedSub, minimalProcess } from './helpers/ir-fixtures.js'; -describe('every template mints its documented form, which the validator reserves', () => { - it.each<[string, () => string, string?]>([ - ['Gateway_AmountCheck_split', () => makeGatewaySplitId('AmountCheck')], - ['Gateway_AmountCheck_join', () => makeGatewayJoinId('AmountCheck')], - ['Gateway_Step1_fork', () => makeGatewayForkId('Step1')], - ['Gateway_OrderWait_race', () => makeGatewayRaceId('OrderWait')], - ['Gateway_MyWhile_loop', () => makeGatewayLoopId('MyWhile')], - [ - 'Flow_Gateway_AmountCheck_split_default', - () => makeDefaultFlowId('Gateway_AmountCheck_split'), - ], - [ - 'Flow_ReviewInvoice_AmountCheck', - () => makeSequenceFlowId('ReviewInvoice', 'AmountCheck', new Set()), - ], - [ - 'StartEvent_invoice-approval', - () => makeStartEventId('invoice-approval', new Set()), - 'invoice-approval', - ], - [ - 'EndEvent_invoice-approval', - () => makeEndEventId('invoice-approval', new Set()), - 'invoice-approval', - ], - [ - 'Throw_invoice-approval_2_t_0', - () => makeThrowEventId('invoice-approval_2_t_0'), - ], - [ - 'EventSubProcess_invoice-approval_0', - () => makeEventSubProcessId('invoice-approval_0'), - ], - [ - 'Catch_invoice-approval_1_c_0', - () => makeIntermediateCatchEventId('invoice-approval_1_c_0'), - ], - [ - 'Boundary_Pack_error', - () => makeBoundaryEventId('Pack', 'error', new Set()), - ], - [ - 'Boundary_Pack_timer_2', - () => - makeBoundaryEventId('Pack', 'timer', new Set(['Boundary_Pack_timer'])), - ], - [ - 'Boundary_Pack_timer_3', - () => - makeBoundaryEventId( - 'Pack', - 'timer', - new Set(['Boundary_Pack_timer', 'Boundary_Pack_timer_2']), - ), - ], - // Minted off a coordinate no container name reaches, so reserved by prefix. - [ - 'EndEvent_Boundary_Pack_timer', - () => makeEndEventId('Boundary_Pack_timer', new Set()), - ], +type Maker = (...args: never[]) => string; + +// A container's own minted start and end are reserved only against that container, named in the last column. +const MINTED: [ + expected: string, + maker: Maker, + args: unknown[], + container?: string, +][] = [ + ['Gateway_AmountCheck_split', makeGatewaySplitId, ['AmountCheck']], + ['Gateway_AmountCheck_join', makeGatewayJoinId, ['AmountCheck']], + ['Gateway_Step1_fork', makeGatewayForkId, ['Step1']], + ['Gateway_OrderWait_race', makeGatewayRaceId, ['OrderWait']], + ['Gateway_MyWhile_loop', makeGatewayLoopId, ['MyWhile']], + [ + 'Flow_Gateway_AmountCheck_split_default', + makeDefaultFlowId, + ['Gateway_AmountCheck_split'], + ], + [ + 'Flow_ReviewInvoice_AmountCheck', + makeSequenceFlowId, + ['ReviewInvoice', 'AmountCheck', new Set()], + ], + [ + 'StartEvent_invoice-approval', + makeStartEventId, + ['invoice-approval', new Set()], + 'invoice-approval', + ], + [ + 'EndEvent_invoice-approval', + makeEndEventId, + ['invoice-approval', new Set()], + 'invoice-approval', + ], + [ + 'Throw_invoice-approval_2_t_0', + makeThrowEventId, + ['invoice-approval_2_t_0'], + ], + [ + 'EventSubProcess_invoice-approval_0', + makeEventSubProcessId, + ['invoice-approval_0'], + ], + [ + 'Catch_invoice-approval_1_c_0', + makeIntermediateCatchEventId, + ['invoice-approval_1_c_0'], + ], + ['Boundary_Pack_error', makeBoundaryEventId, ['Pack', 'error', new Set()]], + [ + 'Boundary_Pack_timer_2', + makeBoundaryEventId, + ['Pack', 'timer', new Set(['Boundary_Pack_timer'])], + ], + [ + 'Boundary_Pack_timer_3', + makeBoundaryEventId, [ - 'StartEvent_EventSubProcess_p_1', - () => makeStartEventId('EventSubProcess_p_1', new Set()), + 'Pack', + 'timer', + new Set(['Boundary_Pack_timer', 'Boundary_Pack_timer_2']), ], - ])('%s', (expected, make, container) => { - const id = make(); + ], + ['Boundary_Pack_timer', boundaryEventIdBase, ['Pack', 'timer']], + // 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()], + ], + ['EndEvent_EventSubProcess_p_1', endIdOf, ['EventSubProcess_p_1']], +]; + +// The exports that read, claim or suffix an id rather than mint one of their own shape. +const NOT_MINTERS = new Set([ + synthesizeIds.isMintedStartId, + synthesizeIds.isMintedEndId, + synthesizeIds.claimId, + synthesizeIds.claimDeclarationName, + synthesizeIds.resolveCollision, +]); + +describe('every template mints its documented form, which the validator reserves', () => { + it.each(MINTED)('%s', (expected, maker, args, container) => { + const id = (maker as (...args: unknown[]) => string)(...args); expect(id).toBe(expected); expect(isReservedName(id, container)).toBe(true); if (container !== undefined) expect(isReservedName(id)).toBe(false); }); + it('no exported id maker is missing from that table', () => { + const covered = new Set(MINTED.map(([, maker]) => maker)); + expect( + Object.entries(synthesizeIds) + .filter( + ([, value]) => + typeof value === 'function' && + !NOT_MINTERS.has(value) && + !covered.has(value), + ) + .map(([name]) => name), + ).toEqual([]); + }); + + it('every diagram id the writer emits, a sub-process expansion included, is reserved', async () => { + const xml = await irToXml( + minimalProcess([chainedSub('Sub', [{ kind: 'task', id: 'T' }])]), + ); + expect( + [...xml.matchAll(/ [ + id, + isReservedName(id!), + ]), + ).toEqual([ + ['BPMNDiagram_p', true], + ['BPMNPlane_p', true], + ['Sub_di', true], + ['T_di', true], + ]); + }); + it('the minted start and end are recognized exactly, per container', () => { const boundaries = ['Boundary_Pack_error']; expect( @@ -141,27 +202,6 @@ describe('a template whose base an author can take claims its result and suffixe }); }); -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', () => { it.each([ ['A', ['B', 'C'], 'A'], diff --git a/packages/transform/test/xml-to-ir.test.ts b/packages/transform/test/xml-to-ir.test.ts index c50e85d..51ca5c0 100644 --- a/packages/transform/test/xml-to-ir.test.ts +++ b/packages/transform/test/xml-to-ir.test.ts @@ -6,17 +6,16 @@ import { fileURLToPath } from 'node:url'; import { asyncRespellingMessage, CAMUNDA_ALIAS_MESSAGE, - COMPENSATION_BOUNDARY_DETAIL, - IS_FOR_COMPENSATION_DETAIL, - priorityRefusal, + shadowedSpellingMessage, unimportedSettingMessage, xmlToIr, } from '../src/xml-to-ir.js'; import type { ImportWarning } from '../src/xml-to-ir.js'; -import { irToXml } from '../src/ir-to-xml.js'; +import { HISTORY_TIME_TO_LIVE, irToXml } from '../src/ir-to-xml.js'; import { irToDsl } from '../src/ir-to-dsl.js'; import { UnsupportedCallActivityError, + UnsupportedVariableNameError, UnsupportedCollaborationError, UnsupportedConditionExpressionError, UnsupportedDocumentError, @@ -55,6 +54,8 @@ import { HANDWRITTEN_IMPORT_IR, ioParam, listValue, + mapEntry, + mapValue, messageDef, minimalProcess, signalDef, @@ -93,30 +94,70 @@ const expectParses = async (source: string): Promise => { expect(doc.diagnostics ?? []).toEqual([]); }; -const expectRewrite = async ( - e: UnsupportedEventFeatureError, - lines: readonly string[], +const HANDLER_TRIGGER = + "note: an event handler catches one 'error', 'escalation', 'message', 'signal', 'timer', 'condition', or 'compensation' trigger on its single start event"; +const THROW_CODE = + 'note: throws and emits carry the code or name their kind requires, and compensation carries neither'; +const THROWN_MESSAGE_FORMS = [ + 'note: the supported forms are a Java class, an expression, a delegate expression, or an external task topic', + "note: their attributes are 'operaton:class' (or the deprecated 'camunda:class' alias), 'operaton:expression', 'operaton:delegateExpression', and 'operaton:type=\"external\"' with 'operaton:topic'", +]; + +const ONE_VALUE_SLOT = + 'note: an injected field names exactly one value slot, and Operaton refuses to deploy one that names none or both of its literal slots'; + +const expectExtensionRefusal = async ( + xml: string, + elementId: string, + detail: string, + notes: 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`); + const e = await expectRefusal( + xmlToIr(xml), + UnsupportedExtensionFormError, + detail, + ); + expect([e.elementId, e.message]).toEqual([ + elementId, + [ + `the Operaton extension content on '${elementId}' cannot be imported: ${detail}`, + ...notes, + ].join('\n'), + ]); }; -const extensionWarnings = (warnings: ImportWarning[]): ImportWarning[] => - warnings.filter((w) => w.category === 'extensionAttribute'); +const IS_FOR_COMPENSATION = + '\'isForCompensation="true"\' marks this activity as excluded from normal flow'; +const EXCLUDED_FROM_FLOW = [ + "note: that is BPMN's boundary-event compensation-handler pattern, which BPMNscript cannot import", + "help: wrap the steps in their own subprocess and target it with 'on compensation'", +]; + +const expectEventRefusal = async ( + xml: string, + elementId: string, + detail: string, + notes: readonly string[], +): Promise => { + const e = await expectRefusal( + xmlToIr(xml), + UnsupportedEventFeatureError, + detail, + ); + expect([e.elementId, e.message]).toEqual([ + elementId, + [`element '${elementId}' cannot be imported: ${detail}`, ...notes].join( + '\n', + ), + ]); + return e; +}; const warning = ( elementId: string, - message: RegExp, + message: string, category: ImportWarning['category'] = 'extensionAttribute', -): { elementId: string; category: string; message: unknown } => ({ - elementId, - category, - message: expect.stringMatching(message), -}); +): ImportWarning => ({ elementId, category, message }); const unread = ( subject: string, @@ -129,34 +170,21 @@ const unread = ( message: unimportedSettingMessage(subject, id, tag, engine), }); +const danglingErrorRef = (ownerId: string, ref: string): ImportWarning => ({ + elementId: ownerId, + category: 'rewritten', + message: + `'errorRef' '${ref}' on '${ownerId}' names no 'bpmn:error' root and imports as the code '${ref}'\n` + + "note: Operaton takes a dangling reference's text as the code ('BpmnParse.parseBoundaryErrorEventDefinition', 'parseErrorStartEventDefinition', 'parseEndEvents', 'parseOperatonErrorEventDefinitions')\n" + + 'note: the rebuilt document declares an error root that carries it', +}); + const CAMUNDA_ALIAS_WARNING = { elementId: 'p', category: 'rewritten', message: CAMUNDA_ALIAS_MESSAGE, }; -const expectOneWarning = ( - warnings: ImportWarning[], - expected: { - elementId: string; - category?: ImportWarning['category']; - message: RegExp | string; - }, -): ImportWarning => { - expect(warnings).toHaveLength(1); - const [warning] = warnings; - expect(warning.elementId).toBe(expected.elementId); - if (expected.category !== undefined) { - expect(warning.category).toBe(expected.category); - } - if (expected.message instanceof RegExp) { - expect(warning.message).toMatch(expected.message); - } else { - expect(warning.message).toContain(expected.message); - } - return warning; -}; - const rootedDoc = (roots: string, body: string, defs = bpmnDefs): string => defs`${roots} @@ -233,32 +261,225 @@ describe('xmlToIr: canonical handwritten file', () => { }); }); -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, - ); +describe('xmlToIr: a document spelling one setting under both prefixes', () => { + const listener = (prefix: string, className: string): string => + `<${prefix}:executionListener event="start" class="${className}" />`; + const inputOutput = (prefix: string, name: string): string => + `<${prefix}:inputOutput><${prefix}:inputParameter name="${name}">1`; + const shadowed = (...names: string[]): ImportWarning => ({ + elementId: 'T', + category: 'extensionAttribute', + message: shadowedSpellingMessage('T', names), }); - it('a connector refuses as a connector', async () => { - const e = await expectRefusal( - xmlToIr( - oneNodeDoc('serviceTask', { - children: extensionElements( - 'http-connector', + it.each([ + { + title: 'an attribute under both keeps the operaton: one', + attrs: 'operaton:class="org.A" camunda:class="org.B"', + children: '', + node: { binding: classBinding('org.A') }, + warnings: [CAMUNDA_ALIAS_WARNING, shadowed('camunda:class')], + }, + { + title: 'an attribute under camunda: alone is read', + attrs: 'camunda:class="org.B"', + children: '', + node: { binding: classBinding('org.B') }, + warnings: [CAMUNDA_ALIAS_WARNING], + }, + { + title: + 'a listener under both keeps every operaton: one and no camunda: one, whatever the order', + attrs: 'operaton:class="org.A"', + children: extensionElements( + listener('camunda', 'org.B') + + listener('operaton', 'org.C') + + listener('camunda', 'org.D'), + ), + node: { + binding: classBinding('org.A'), + executionListeners: [ + { event: 'start', binding: classBinding('org.C') }, + ], + }, + warnings: [ + CAMUNDA_ALIAS_WARNING, + shadowed(''), + ], + }, + { + title: + 'a listener under camunda: alone is read beside other operaton: content', + attrs: 'operaton:class="org.A"', + children: extensionElements( + listener('camunda', 'org.B') + inputOutput('operaton', 'a'), + ), + node: { + binding: classBinding('org.A'), + executionListeners: [ + { event: 'start', binding: classBinding('org.B') }, + ], + inputParameters: [ioParam('a', textValue('1'))], + }, + warnings: [CAMUNDA_ALIAS_WARNING], + }, + { + title: + 'an extension element under both keeps the operaton: one, and one warning names every ignored spelling', + attrs: 'operaton:class="org.A" camunda:class="org.B"', + children: extensionElements( + inputOutput('camunda', 'b') + inputOutput('operaton', 'a'), + ), + node: { + binding: classBinding('org.A'), + inputParameters: [ioParam('a', textValue('1'))], + }, + warnings: [ + CAMUNDA_ALIAS_WARNING, + shadowed('camunda:class', ''), + ], + }, + { + title: + 'list items and map entries under both are all read, since BpmnParseUtil reads them by tag alone', + attrs: 'operaton:class="org.A"', + children: extensionElements( + '' + + 'ba' + + '' + + 'ba' + + '', + ), + node: { + binding: classBinding('org.A'), + inputParameters: [ + ioParam('x', listValue([textValue('b'), textValue('a')])), + ioParam( + 'y', + mapValue([ + mapEntry('k', textValue('b')), + mapEntry('j', textValue('a')), + ]), ), - }), + ], + }, + warnings: [CAMUNDA_ALIAS_WARNING], + }, + { + title: + 'error mappings under both are all read, since parseOperatonErrorEventDefinitions fetches them by tag alone', + attrs: 'operaton:type="external" operaton:topic="t"', + children: extensionElements( + '' + + '', ), - UnsupportedServiceTaskFormError, + node: { + binding: { + ...externalBinding('t'), + errorMappings: [ + { errorCode: 'B', condition: '${x}' }, + { errorCode: 'A', condition: '${y}' }, + ], + }, + }, + warnings: [ + danglingErrorRef('T', 'B'), + danglingErrorRef('T', 'A'), + CAMUNDA_ALIAS_WARNING, + ], + }, + { + title: + 'a prefix redeclared onto the operaton URI on one element counts as operaton:', + attrs: + 'xmlns:c="http://operaton.org/schema/1.0/bpmn" c:class="org.A" camunda:class="org.B"', + children: '', + node: { binding: classBinding('org.A') }, + warnings: [CAMUNDA_ALIAS_WARNING, shadowed('camunda:class')], + }, + { + title: + 'a default-namespace camunda: child is cut beside an operaton: one', + attrs: 'operaton:class="org.A"', + children: extensionElements( + '' + + listener('operaton', 'org.C'), + ), + node: { + binding: classBinding('org.A'), + executionListeners: [ + { event: 'start', binding: classBinding('org.C') }, + ], + }, + warnings: [CAMUNDA_ALIAS_WARNING, shadowed('')], + }, + { + title: + 'a > inside an earlier attribute value does not end the start tag early', + attrs: 'name="a > b" operaton:class="org.A" camunda:class="org.B"', + children: '', + node: { name: 'a > b', binding: classBinding('org.A') }, + warnings: [CAMUNDA_ALIAS_WARNING, shadowed('camunda:class')], + }, + { + title: + 'CRLF line ends and a start tag over several lines still cut the right range', + attrs: + 'operaton:class="org.A"\r\n camunda:class="org.B"\r\n ', + children: extensionElements( + '\r\n' + + listener('camunda', 'org.B') + + '\r\n' + + listener('operaton', 'org.C'), + ), + node: { + binding: classBinding('org.A'), + executionListeners: [ + { event: 'start', binding: classBinding('org.C') }, + ], + }, + warnings: [ + CAMUNDA_ALIAS_WARNING, + shadowed('camunda:class', ''), + ], + }, + { + title: + 'a camunda: child inside a cut camunda: child goes with it and adds no warning', + attrs: 'operaton:class="org.A"', + children: extensionElements( + '1' + + '2' + + '3', + ), + node: { + binding: classBinding('org.A'), + inputParameters: [ioParam('a', textValue('1'))], + }, + warnings: [CAMUNDA_ALIAS_WARNING, shadowed('')], + }, + ])('$title', async ({ attrs, children, node, warnings: expected }) => { + const { ir, warnings } = await xmlToIr( + oneNodeDoc('serviceTask', { attrs, children, doc: dualDoc }), ); - 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(byId(ir, 'T')).toEqual({ kind: 'serviceTask', id: 'T', ...node }); + expect(warnings).toEqual(expected); + }); +}); + +describe('xmlToIr: a malformed document declaring both URIs', () => { + it('is reported by moddle exactly as when it declares one', async () => { + const malformed = dualDoc``; + const message = async (xml: string): Promise => + xmlToIr(xml).then( + () => 'imported', + (e: unknown) => (e as Error).message, + ); + const one = await message( + malformed.replace('http://camunda.org/schema/1.0', 'http://other.org/x'), ); - expect(e.subject).toBe('Service task'); + expect(one).toMatch(/unparsable content/); + expect(await message(malformed)).toBe(one); }); }); @@ -273,10 +494,8 @@ describe('xmlToIr: script task support', () => { 'x = 1;', { format: 'juel' }, 'rewritten', - "The script on 'ST' has no scriptFormat; " + - '`BpmnParse.parseScriptTaskElement` substitutes ' + - '`ScriptingEngines.DEFAULT_SCRIPTING_LANGUAGE` (juel), and it was ' + - 'imported as such.', + "script on 'ST' has no 'scriptFormat' and imports as 'juel'\n" + + "note: 'BpmnParse.parseScriptTaskElement' substitutes 'ScriptingEngines.DEFAULT_SCRIPTING_LANGUAGE', which is 'juel'", ], [ 'a scriptFormat outside the alias table is carried as written', @@ -284,22 +503,19 @@ describe('xmlToIr: script task support', () => { '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.', + "script on 'ST' names the language 'cobol', which BPMNscript has no fence alias for\n" + + 'note: 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', + 'a self-closing bpmn:script imports as an empty body without a warning, 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.', + undefined, + undefined, ], [ - 'a self-closing operaton:script on a listener imports as an empty body, since ScriptUtil.getScript deploys it', + 'a self-closing operaton:script on a listener imports as an empty body without a warning, since ScriptUtil.getScript deploys it', 'scriptFormat="javascript"', ` @@ -316,11 +532,8 @@ describe('xmlToIr: script task support', () => { }, ], }, - 'carriedAsWritten', - 'The body of the operaton:script in an operaton:executionListener is ' + - 'empty: ScriptUtil.getScript deploys it, since it checks the source ' + - 'for null and not for emptiness, and the printed script draws an ' + - 'empty-body error there.', + undefined, + undefined, ], ] as const)( '%s', @@ -330,7 +543,9 @@ describe('xmlToIr: script task support', () => { 'scriptTask', ); expect(node).toMatchObject(carried); - expect(warnings).toEqual([{ elementId: 'ST', category, message }]); + expect(warnings).toEqual( + category === undefined ? [] : [{ elementId: 'ST', category, message }], + ); }, ); @@ -345,9 +560,8 @@ describe('xmlToIr: script task support', () => { 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'.", + "script task 'ST' has an unsupported language tag 'http://www.java.com/java'\n" + + "help: use 'juel', 'js', 'javascript', 'ecmascript', 'groovy', 'py', 'python', 'rb', 'ruby', or 'feel'", ], ]); }); @@ -357,40 +571,59 @@ describe('xmlToIr: script task support', () => { 'an empty scriptFormat refuses', 'scriptFormat=""', 'x = 1;', - "the script on 'ST' has an empty scriptFormat; " + - '`ScriptUtil.getScript` refuses to deploy it', + "the script on 'ST' has an empty 'scriptFormat'", + "note: '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', + "the script on 'ST' has neither a script body nor a resource", + "note: 'ScriptUtil.getScript' refuses to deploy it with neither", ], - ])('%s', async (_title, attrs, children, detail) => { - await expectRefusal( - xmlToIr(scriptTask(attrs, children)), - UnsupportedExtensionFormError, - detail, - ); + ])('%s', async (_title, attrs, children, detail, note) => { + await expectExtensionRefusal(scriptTask(attrs, children), 'ST', detail, [ + note, + ]); }); }); 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); - }); + it.each([ + [ + 'an ad-hoc subprocess names its id', + oneNodeDoc('adHocSubProcess', { + children: '', + doc: bpmnDoc, + }), + 'bpmn:AdHocSubProcess', + 'T', + "element 'T' of the kind 'bpmn:AdHocSubProcess' cannot be imported", + ], + [ + 'an element with no id is named by its kind alone', + bpmnDoc` + + + `, + 'bpmn:ComplexGateway', + undefined, + "an element of the kind 'bpmn:ComplexGateway' cannot be imported", + ], + ])( + '%s, and lists every kind that imports', + async (_title, xml, qname, elementId, head) => { + const e = await expectRefusal( + xmlToIr(xml), + UnsupportedElementError, + ); + expect([e.qname, e.elementId, e.message]).toEqual([ + qname, + elementId, + `${head}\n${SUPPORTED_KINDS_MESSAGE}`, + ]); + }, + ); }); describe('xmlToIr: the document level', () => { @@ -404,7 +637,7 @@ describe('xmlToIr: the document level', () => { const EXECUTABLE = process(' isExecutable="true"'); const collaboration = (children: string): string => ` \n${children}\n `; - const unmapped = (elementId: string, message: RegExp) => + const unmapped = (elementId: string, message: string) => warning(elementId, message, 'unmappedConstruct'); const twoPools = collaboration(` @@ -418,19 +651,17 @@ describe('xmlToIr: the document level', () => { const twoPoolsWarnings = [ unmapped( 'sketch', - /^The process 'sketch' is not marked executable and was not imported; `BpmnParse.parseProcessDefinitions` does not deploy it either\.$/, + "process 'sketch' is not marked executable and was not imported\nnote: '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\.$/, + "pool 'Approval' (P1) was not imported\nnote: it names the imported process 'approval'\nnote: 'BpmnParse.parseCollaboration' records it for the diagram alone\nnote: the rebuilt document has no pool", ), unmapped( 'P2', - /^The pool 'Sketch' \(P2\) names process 'sketch', which was not imported; `BpmnParse\.parseCollaboration`/, + "pool 'Sketch' (P2) was not imported\nnote: it names process 'sketch', which was not imported\nnote: 'BpmnParse.parseCollaboration' records it for the diagram alone\nnote: the rebuilt document has no pool", ), ]; - const VALIDATING_PARSE = - /Operaton's validating parse \(`Parse\.execute` against `BPMN20\.xsd`/; it.each([ [ @@ -443,11 +674,11 @@ ${EXECUTABLE}`, warnings: [ unmapped( 'P1', - /^The pool 'Approval' \(P1\) names the imported process 'p'/, + "pool 'Approval' (P1) was not imported\nnote: it names the imported process 'p'\nnote: 'BpmnParse.parseCollaboration' records it for the diagram alone\nnote: the rebuilt document has no pool", ), 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\.$/, + "message flow 'MF' from 'A' to 'P1' was not imported\nnote: 'BpmnParse' reads no message flow, so the process runs the same without it", ), ], }, @@ -462,7 +693,8 @@ ${EXECUTABLE}`, bpmnDefs`${process(' isExecutable="true"', 'p1')}\n${process(' isExecutable="true"', 'p2', ' ')}`, { refusal: UnsupportedCollaborationError, - detail: /^two executable processes \('p1', 'p2'\)$/, + message: + "the file contains two executable processes ('p1', 'p2')\nnote: BPMNscript imports one process\nnote: 'BpmnParse.parseProcessDefinitions' deploys each of them", }, ], [ @@ -470,8 +702,8 @@ ${EXECUTABLE}`, bpmnDefs`${process('', 'p1')}\n${process('', 'p2', ' ')}`, { refusal: UnsupportedDocumentError, - detail: - /^none of its 2 processes is marked isExecutable="true"; `BpmnParse\.parseProcessDefinitions` deploys none of them/, + message: + "the document cannot be imported: none of its 2 processes is marked 'isExecutable=\"true\"'\nnote: 'BpmnParse.parseProcessDefinitions' deploys none of them in a new deployment", }, ], [ @@ -482,7 +714,7 @@ ${EXECUTABLE}`, 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\.$/, + "process 'p' was imported as executable although it is not marked 'isExecutable=\"true\"'\nnote: 'BpmnParse.parseProcessDefinitions' skips such a process in a new deployment\nnote: it is written back as executable", 'behaviourChanged', ), ], @@ -496,7 +728,8 @@ ${EXECUTABLE}`, ), { refusal: UnsupportedDocumentError, - detail: /^it carries a declaration.*disallow-doctype-decl/, + message: + "the document cannot be imported: it carries a '' declaration\nnote: Operaton refuses to deploy it, since 'Parser.setXxeProcessing' sets 'disallow-doctype-decl' unless XXE processing is enabled\nnote: BPMNscript does not expand its entities", }, ], [ @@ -504,8 +737,8 @@ ${EXECUTABLE}`, 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/, + message: + "the document cannot be imported: it references the entity ' '\nnote: XML does not predefine it and no DOCTYPE declares it, so Operaton's parse fails on it\nnote: BPMNscript would keep it as literal text", }, ], [ @@ -529,9 +762,10 @@ ${EXECUTABLE}`, bpmnDefs`${process(' isExecutable="true"', 'p', STEPS.replace('', ''))}`, { refusal: UnsupportedDocumentError, - detail: new RegExp( - `^isSequential="yes" is outside true and false.*${VALIDATING_PARSE.source}`, - ), + message: + "the document cannot be imported: isSequential is 'yes', which is neither 'true' nor 'false'\n" + + "note: BPMNscript reads the schema's boolean\n" + + "note: Operaton's validating parse ('Parse.execute' against 'BPMN20.xsd', which types 'isSequential' as 'xsd:boolean') refuses the deployment before 'BpmnParse' reads it", }, ], [ @@ -539,8 +773,8 @@ ${EXECUTABLE}`, 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/, + message: + "the document cannot be imported: the id 'A' is written on two elements\nnote: Operaton validates every file against 'BPMN20.xsd' and refuses a duplicate 'xs:ID'", }, ], [ @@ -548,10 +782,12 @@ ${EXECUTABLE}`, 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', + detail: "the id 'Auftrag_prüfen' is outside what BPMNscript reads", + message: + "the document cannot be imported: the id 'Auftrag_prüfen' is outside what BPMNscript reads\n" + + "note: BPMNscript reads ASCII letters, digits, '_', '-' and '.', starting with a letter or '_'\n" + + 'note: the schema admits any letter\n' + + 'help: rename it', }, ], [ @@ -560,8 +796,8 @@ ${EXECUTABLE}`, ${EXECUTABLE}`, { refusal: UnsupportedDocumentError, - detail: - /^it declares a bpmn:import of type 'http:\/\/www\.w3\.org\/2001\/XMLSchema'; `BpmnParse\.parseImports` fails the deployment/, + message: + "the document cannot be imported: it declares a 'bpmn:import' of type 'http://www.w3.org/2001/XMLSchema'\nnote: 'BpmnParse.parseImports' fails the deployment on every import type but WSDL, and on WSDL without 'CxfWSDLImporter' on the classpath", }, ], [ @@ -571,8 +807,8 @@ ${EXECUTABLE}`, ${EXECUTABLE}`, { refusal: UnsupportedEventFeatureError, - detail: - /^signal roots 'Signal_1' and 'Signal_2' both declare the name "Go"; `BpmnParse\.parseSignals` fails the deployment/, + message: + "element 'Signal_2' cannot be imported: signal roots 'Signal_1' and 'Signal_2' both declare the name 'Go'\nnote: 'BpmnParse.parseSignals' fails the deployment on a duplicate signal name\nhelp: leave one root per signal name", }, ], [ @@ -580,9 +816,19 @@ ${EXECUTABLE}`, 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', + detail: 'its process has no id', + message: + 'the document cannot be imported: its process has no id\n' + + 'note: Operaton deploys a process under its id as the definition key, and the deployment fails without one', + }, + ], + [ + 'a document with no bpmn:process refuses, since there is nothing to import', + bpmnDefs` `, + { + refusal: UnsupportedDocumentError, + detail: "it holds no 'bpmn:process'", + message: "the document cannot be imported: it holds no 'bpmn:process'", }, ], [ @@ -595,13 +841,18 @@ ${EXECUTABLE}`, 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"\)/, + message: + "element 'p' cannot be imported: it has no start event\nnote: 'BpmnParse.parseStartEvents' fails the deployment on it (\"process must define a startEvent element\")\nhelp: add a start event and lead it to the first step", }, ], ] as const)('%s', async (_title, xml, expected) => { if ('refusal' in expected) { - await expectRefusal(xmlToIr(xml), expected.refusal, expected.detail); + const e = await expectRefusal( + xmlToIr(xml), + expected.refusal, + 'detail' in expected ? expected.detail : undefined, + ); + expect(e.message).toBe(expected.message); return; } const { ir, warnings } = await xmlToIr(xml); @@ -627,7 +878,7 @@ ${EXECUTABLE}`, `, ['S', 'A', 'E', 'UserTask_p', 'UserTask_p_2', 'EndEvent_p_2'], ['F1', 'F2'], - [4, 5, 6], + ['UserTask_p', 'UserTask_p_2', 'EndEvent_p_2'], ], [ "an end event imports clear of a boundary escape's implicit end, whose boundary id is the container's with a counter", @@ -639,30 +890,26 @@ ${EXECUTABLE}`, `, ['S', 'A', 'E', 'E2', 'p_2', 'EndEvent_p_3'], ['F1', 'F2', 'F3'], - [4], + ['EndEvent_p_3'], ], ])( 'an element without an id, which the engine deploys: %s', - async (_title, content, nodes, flows, neverRunLines) => { + async (_title, content, nodes, flows, leftOut) => { const { ir, warnings } = await xmlToIr( bpmnDefs`${process(' isExecutable="true"', 'p', ` \n \n \n${content}`)}`, ); - // Nothing can flow into a node without an id, so its step never runs. - const { diagnostics } = await validate(irToDsl(ir).source); + // Nothing can flow into a node without an id, so the print leaves it out. + const printed = irToDsl(ir); + const { diagnostics } = await validate(printed.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:/), - ]), - ]); + printed.warnings + .filter((w) => w.category === 'unreachable') + .map((w) => w.elementId), + diagnostics, + ]).toEqual([nodes, flows, [], leftOut, []]); }, ); @@ -672,9 +919,9 @@ ${EXECUTABLE}`, ' \n \n \n'; const DEPLOY = (method: string, quote = ''): string => - `, which ${method} fails the deployment on${quote}`; + `note: '${method}' fails the deployment on it${quote}`; const RUN = (method: string, when: string): string => - `: Operaton deploys it and ${method} fails the run ${when}`; + `note: Operaton deploys it, and '${method}' fails the run ${when}`; const NO_ID = '("boundary event has no id")'; const boundary = (def: string): string => `${LINEAR} ${def}`; @@ -795,67 +1042,72 @@ ${EXECUTABLE}`, ])( 'an element without an id, which the engine refuses: %s', async (_title, content, tag, container, failure) => { - await expectRefusal( + const err = await expectRefusal( xmlToIr( bpmnDefs` ${process(' isExecutable="true"', 'p', content)}`, ), UnsupportedDocumentError, - `a bpmn:${tag} in '${container}' has no id${failure}; give it one`, + `a 'bpmn:${tag}' in '${container}' has no id`, ); + expect(err.message.split('\n').slice(1)).toEqual([ + failure, + 'help: give it an id', + ]); }, ); const HERE = "without an id in 'p')"; + const ELEMENT = (tag: string): string => `(a 'bpmn:${tag}' ${HERE}`; it.each([ [ 'a compensation boundary, which the engine runs, reaches the compensation refusal', boundary(''), UnsupportedEventFeatureError, - `The element (a bpmn:boundaryEvent ${HERE} cannot be imported`, + `element ${ELEMENT('boundaryEvent')} 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`, + `element ${ELEMENT('boundaryEvent')} 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', + "element (a 'bpmn:boundaryEvent' without an id) cannot be imported", ], [ 'a compensation handler', `${LINEAR} `, UnsupportedEventFeatureError, - `The element (a bpmn:userTask ${HERE} cannot be imported`, + `element ${ELEMENT('userTask')} cannot be imported`, ], [ 'a link throw naming no link', `${LINEAR} `, UnsupportedEventFeatureError, - `The element (a bpmn:intermediateThrowEvent ${HERE} cannot be imported`, + `element ${ELEMENT('intermediateThrowEvent')} cannot be imported`, ], [ 'a service task with no binding', `${LINEAR} `, UnsupportedServiceTaskFormError, - `Service task (a bpmn:serviceTask ${HERE} uses unsupported execution form`, + `service task ${ELEMENT('serviceTask')} uses an unsupported execution form`, ], [ 'two input parameters sharing a name', `${LINEAR} 12`, UnsupportedExtensionFormError, - `The Operaton extension content on (a bpmn:userTask ${HERE} cannot be imported`, + `the Operaton extension content on ${ELEMENT('userTask')} cannot be imported`, ], [ 'a call activity naming nothing to call', `${LINEAR} `, UnsupportedCallActivityError, - `The call activity (a bpmn:callActivity ${HERE} cannot be imported`, + `call activity ${ELEMENT('callActivity')} cannot be imported`, ], ] as const)( 'an element without an id refused past the id check names no minted id: %s', @@ -936,6 +1188,10 @@ ${children} `; + const IGNORED = [ + 'note: Operaton ignores the trigger and starts the process as if none were written', + 'note: importing it would write back a document the engine runs differently from what it says', + ]; const subWith = (tag: string, content: string): string => bpmnDoc` <${tag} id="Sub"> @@ -950,31 +1206,31 @@ ${content} '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 process cannot start on an error', + [ + ...IGNORED, + "help: 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 process cannot start on an escalation', + [ + ...IGNORED, + "help: 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 process cannot start on compensation', + [ + ...IGNORED, + "help: compensation undoes a subprocess's completed work, so put it in an 'on compensation' block inside that subprocess", + ], ], [ 'a message start whose message name embeds an expression', @@ -982,11 +1238,11 @@ ${content} 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.', + "the message name 'Order-${type}' of a message start event is an expression", + [ + 'note: Operaton rejects an expression there, because a process that has not started yet has no variables to evaluate it against', + 'help: give the message a fixed name', + ], ], [ 'two plain starts in a process, which selectInitial fails the deployment on', @@ -998,11 +1254,11 @@ ${content} `, '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, + "the process 'p' has 2 plain or timer starts", + [ + 'note: \'BpmnParse.selectInitial\' fails the deployment on it ("multiple none start events or timer start events not supported on process definition")', + 'help: leave one plain or timer start, and give the others a message, signal or condition trigger', + ], ], [ 'two starts in a subprocess', @@ -1014,9 +1270,28 @@ ${content} `, ), '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, + 'it has 2 start events', + [ + 'note: BPMNscript writes one entry point per subprocess or transaction, so a second start has nowhere to go', + 'help: leave one start and connect the steps that followed the others onto it', + ], + ], + [ + 'a trigger on the start of a subprocess', + oneNodeDoc('subProcess', { + id: 'Sub', + doc: bpmnDoc, + children: ` + + `, + }), + 'SubStart', + 'a subprocess cannot start on a trigger', + [ + 'note: Operaton rejects one there when it parses the file', + 'note: a subprocess is entered from the surrounding process, not by an event of its own', + "help: put the trigger on an 'on' handler inside the subprocess if it should react to an event", + ], ], [ 'a connector on a message end, the element form of the send', @@ -1029,9 +1304,11 @@ ${content} ), ), '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 thrown message carries a connector', + [ + 'note: the connector is what makes the engine really send it, and BPMNscript has no place to keep it', + ...THROWN_MESSAGE_FORMS, + ], ], [ 'a class with a resultVariable on an emit, quoting the engine', @@ -1041,11 +1318,10 @@ ${content} ), ), '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, + "its message definition binds a Java class with a result variable ('operaton:class', 'operaton:resultVariable')", + [ + "note: Operaton refuses to deploy it with \"'resultVariableName' not supported for intermediateMessageThrowEvent elements using 'class'\" ('BpmnParse.parseServiceTaskLike')", + ], ], [ 'an expression with a resultVariable on a message end, whose value the engine stores', @@ -1055,26 +1331,14 @@ ${content} ), ), '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, + "its message definition binds an expression with a result variable ('operaton:expression', 'operaton:resultVariable=\"r\"')", + [ + "note: 'BpmnParse.parseServiceTaskLike' stores the expression's value under it ('ServiceTaskExpressionActivityBehavior')", + 'note: a thrown message in BPMNscript takes no result variable, so dropping it would change what runs', + ], ], - ])('%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}`, - ); - } + ])('%s refuses', async (_title, xml, elementId, detail, notes) => { + await expectEventRefusal(xml, elementId, detail, notes); }); it('a bpmn:transaction with no start event imports and warns that the engine fails it on entry', async () => { @@ -1095,10 +1359,9 @@ ${content} 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.', + "transaction 'Sub' has no start event\n" + + 'note: Operaton deploys it, and \'SubProcessActivityBehavior.execute\' fails on entering it ("No initial activity found")\n' + + 'note: the printed script adds a start, so the imported block runs', }, ]); }); @@ -1111,30 +1374,30 @@ ${content} `; 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.`; + `setting '${attr}' on 'Typed' was not imported\n` + + "note: '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.'; + `${what} on 'Typed' was not imported\n` + + `note: 'BpmnParse.parseExternalServiceTask' reads it off ${carrier} of a ` + + `thrown message bound with 'operaton:type="external"'\n` + + 'note: the throw in BPMNscript has no position for it, so the rebuilt ' + + 'document 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.'; + `${what} on 'Typed' was not imported\n` + + "note: Operaton reads it in 'parseExternalServiceTask' alone, which " + + `only a thrown message bound with 'operaton:type="external"' reaches\n` + + 'note: 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.'; + `${what} on 'TStart' was not imported\n` + + "note: it only takes effect on a throw ('throw message' or 'emit " + + "message'), and has no effect on a catch"; const FIELD_HOME = - 'this tool carries an injected field on the step or the listener ' + + 'BPMNscript 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'; @@ -1155,9 +1418,8 @@ ${content} 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.', + "setting 'topic' on 'Typed' was not imported\n" + + 'note: it only takes effect alongside \'operaton:type="external"\', and on its own names no external worker', ], }, { @@ -1170,7 +1432,7 @@ ${content} id: 'Typed', binding: externalBinding('send-ack'), warnings: [ - neverWritten('resultVariable', 'an operaton:type="external" binding'), + neverWritten('resultVariable', `an 'operaton:type="external"' binding`), ], }, { @@ -1196,10 +1458,10 @@ ${content} 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.', + "injected field 'to' on the message definition of 'Typed' was not imported\n" + + `note: ${FIELD_HOME}\n` + + "note: 'BpmnParse.parseServiceTaskLike' reads it off the definition " + + 'into the class it names, so the rebuilt document runs that without it', ], }, { @@ -1213,9 +1475,9 @@ ${content} 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.', + "injected field 'to' on the message definition of 'Typed' was not imported\n" + + `note: ${FIELD_HOME}\n` + + 'note: Operaton injects a field into a class or a delegate binding and into no other', ], }, { @@ -1229,8 +1491,8 @@ ${content} id: 'Typed', binding: externalBinding('send-ack'), warnings: [ - readOffThrow("'taskPriority' setting", 'the message definition'), - readOffThrow('operaton:properties block', 'the event element'), + readOffThrow("setting 'taskPriority'", 'the message definition'), + readOffThrow("'operaton:properties' block", 'the event element'), ], }, { @@ -1246,9 +1508,9 @@ ${content} id: 'Typed', binding: classBinding('com.example.Send'), warnings: [ - neverReadOnThrow("'taskPriority' setting"), - neverReadOnThrow("operaton:errorEventDefinition 'Map_1'"), - neverReadOnThrow('operaton:properties block'), + neverReadOnThrow("setting 'taskPriority'"), + neverReadOnThrow("'operaton:errorEventDefinition' named 'Map_1'"), + neverReadOnThrow("'operaton:properties' block"), ], }, { @@ -1264,10 +1526,10 @@ ${content} id: 'TStart', binding: undefined, warnings: [ - throwOnly("'taskPriority' setting"), - throwOnly("'resultVariable' setting"), - throwOnly("operaton:field 'to'"), - throwOnly("operaton:errorEventDefinition 'Map_1'"), + throwOnly("setting 'taskPriority'"), + throwOnly("setting 'resultVariable'"), + throwOnly("'operaton:field' named 'to'"), + throwOnly("'operaton:errorEventDefinition' named 'Map_1'"), ], }, ])('$case', async ({ xml, id, binding, warnings: expected }) => { @@ -1285,17 +1547,23 @@ describe('xmlToIr: imports a repetition', () => { const repeatedTaskDoc = (attrs = '', children = ''): string => oneNodeDoc('userTask', { children: repeat(attrs, children) }); - const on = ( - category: ImportWarning['category'], - message: string | ReturnType, - ) => ({ elementId: 'T', category, message }); + const on = (category: ImportWarning['category'], message: string) => ({ + elementId: 'T', + category, + message, + }); const both = (pair: string, rule: string, kept: string, dropped: string) => on( 'extensionAttribute', - `Both ${pair} on 'T'; ${rule}, so '${kept}' was imported and '${dropped}' was dropped.`, + pair.replace(' name ', ` on 'T' both name `) + + `, and '${dropped}' was dropped\nnote: ${rule}\nnote: '${kept}' was imported`, ); + const unreadOnRepetition = (what: string): string => + `${what} on the repetition of 'T' was not imported\n` + + 'note: Operaton does not read it, so the imported process runs the same'; + it.each<[string, string, string, object, unknown[]]>([ [ 'isSequential="true" imports the runs as sequential', @@ -1311,8 +1579,8 @@ describe('xmlToIr: imports a repetition', () => { { collection: 'lines' }, [ both( - 'operaton:collection and bpmn:loopDataInputRef name the collection', - 'parseMultiInstanceLoopCharacteristics writes both into the same field and bpmn:loopDataInputRef second', + "'operaton:collection' and 'bpmn:loopDataInputRef' name the collection", + "'parseMultiInstanceLoopCharacteristics' writes both into the same field, and 'bpmn:loopDataInputRef' second", 'lines', 'items', ), @@ -1325,10 +1593,10 @@ describe('xmlToIr: imports a repetition', () => { { 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 ' + + "'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', @@ -1342,8 +1610,8 @@ describe('xmlToIr: imports a repetition', () => { { 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', + "'bpmn:inputDataItem' and 'operaton:elementVariable' name what each run sees", + "'parseMultiInstanceLoopCharacteristics' writes both into the same field, and 'bpmn:inputDataItem' second", 'line', 'item', ), @@ -1360,19 +1628,16 @@ describe('xmlToIr: imports a repetition', () => { [ 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.', + "'bpmn:completionCondition' on 'T' is the bare text 'nrOfCompletedInstances > 1' and is rewritten as an expression\n" + + "note: it has no '${...}' or '#{...}' opener\n" + + "note: 'parseMultiInstanceLoopCharacteristics' hands it to 'createExpression' as a literal, and 'MultiInstanceActivityBehavior.completionConditionSatisfied' throws 'expressionNotBooleanException' when the first run completes\n" + + "note: 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.', + "'language=\"groovy\"' on the 'bpmn:completionCondition' of 'T' was not imported\n" + + "note: 'parseMultiInstanceLoopCharacteristics' hands the text alone to 'createExpression' and reads no language\n" + + 'note: the imported step runs the same', ), ], ], @@ -1389,26 +1654,27 @@ describe('xmlToIr: imports a repetition', () => { 'a bpmn:loopDataOutputRef', '', 'results', - 'The bpmn:loopDataOutputRef on the repetition', + unreadOnRepetition("'bpmn:loopDataOutputRef'"), ], [ 'behavior="One"', 'behavior="One"', '', - 'The behavior="One" on the repetition', + unreadOnRepetition(`'behavior="One"'`), ], [ '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.', + unreadOnRepetition("'operaton:jobPriority'"), ], [ 'an attribute BPMN does not declare', 'bogus="x"', '', - "The 'bogus' attribute on 'T' is not declared by BPMN", + "attribute 'bogus' on 'T' was not imported\n" + + 'note: BPMN does not declare it\n' + + 'note: BPMNscript imports the executable flow and the engine settings on its steps, and nothing declared or drawn beside it', ], ] as const ).map( @@ -1423,7 +1689,7 @@ describe('xmlToIr: imports a repetition', () => { `operaton:collection="lines" ${attrs}`, children, { collection: 'lines' }, - [on('unmappedConstruct', expect.stringContaining(message))], + [on('unmappedConstruct', message)], ], ), ])('%s', async (_title, attrs, children, loop, expected) => { @@ -1439,18 +1705,20 @@ describe('xmlToIr: imports a repetition', () => { [ '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', + 'it sets neither a number of runs nor a collection to run over', + 'Operaton refuses to deploy that', ], [ 'an empty bpmn:loopCardinality', repeatedTaskDoc('', ''), - 'its bpmn:loopCardinality is empty, so Operaton has no number of runs to read', + "its 'bpmn:loopCardinality' is empty", + 'Operaton has no number of runs to read', ], [ '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', + "its 'bpmn:loopCardinality' is '3.5', which BPMNscript cannot write back unchanged", + 'BPMNscript writes a count as a plain whole number or as an expression, and this body is neither', ], [ 'an element variable with no collection', @@ -1458,7 +1726,8 @@ describe('xmlToIr: imports a repetition', () => { '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", + "it names 'line' for each run to see but no collection to take it from", + 'Operaton refuses to deploy that', ], [ 'a bpmn:inputDataItem name outside the identifier the clause writes', @@ -1466,8 +1735,16 @@ describe('xmlToIr: imports a repetition', () => { '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', + "it names 'my var' for each run to see, which BPMNscript cannot write back unchanged", + 'BPMNscript writes that name as a plain identifier, and this name is not one', + ], + [ + 'an element variable named by a keyword', + repeatedTaskDoc( + 'operaton:collection="lines" operaton:elementVariable="var"', + ), + "it names 'var' for each run to see, which BPMNscript cannot write back unchanged", + 'BPMNscript writes that name as a plain identifier, and this name is not one', ], [ 'an operaton:outputParameter on a repeated step', @@ -1478,19 +1755,20 @@ describe('xmlToIr: imports a repetition', () => { `, )}${repeat('operaton:collection="lines"')}`, }), - 'it maps an \'operaton:outputParameter\', which BpmnParse.checkActivityOutputParameterSupported fails the deployment on ("operaton:outputParameter not allowed for multi-instance constructs")', + "it maps an 'operaton:outputParameter'", + '\'BpmnParse.checkActivityOutputParameterSupported\' fails the deployment on it ("operaton:outputParameter not allowed for multi-instance constructs")', ], - ])('%s is refused', async (_title, xml, detail) => { + ])('%s is refused', async (_title, xml, detail, note) => { 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([e.elementId, e.loopType, e.message]).toEqual([ + 'T', + 'bpmn:MultiInstanceLoopCharacteristics', + `repetition on 'T' cannot be imported: ${detail}\nnote: ${note}`, + ]); }); const loopedHandler = (loop: string, roots = ''): string => @@ -1503,14 +1781,18 @@ describe('xmlToIr: imports a repetition', () => { ); it('an event handler carrying a multi-instance repetition is refused', async () => { - await expectRefusal( + const e = await expectRefusal( xmlToIr( loopedHandler( repeat('', '2'), ), ), UnsupportedLoopCharacteristicsError, - 'an event handler is entered by its trigger, so it cannot be repeated', + 'an event handler cannot be repeated', + ); + expect(e.message).toBe( + "repetition on 'Handler' cannot be imported: an event handler cannot be repeated\n" + + 'note: an event handler is entered by its trigger', ); }); @@ -1527,9 +1809,9 @@ describe('xmlToIr: imports a repetition', () => { 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.', + "'bpmn:standardLoopCharacteristics' on 'Handler' was not imported\n" + + 'note: Operaton does not run one at all, it deploys the step and runs it once\n' + + 'note: the imported step runs once too', }, ]); }); @@ -1542,32 +1824,33 @@ describe('xmlToIr: a #{...} body the printer spells bare is rebuilt inside ${... }); 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.'; + `'${slot}' on '${id}' is rewritten from '#{...}' to '\${...}'\n` + + 'note: the script prints its body as bare DSL, and the rebuilt ' + + "document writes it inside '${...}'\n" + + 'note: Operaton evaluates both the same'; - it.each([ - [ - 'a loop cardinality', + it('a loop cardinality reports the rewrap', async () => { + const { warnings } = await xmlToIr( 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']); + ); + expect(warnings).toEqual([ + { + elementId: 'T', + category: 'rewritten', + message: rewrapped('bpmn:loopCardinality', 'T'), + }, + ]); }); }); 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` + operatonDoc` @@ -1579,60 +1862,103 @@ describe('xmlToIr: a scripted condition on a sequence flow or a conditional even `; const conditionOf = (ir: BpmnProcess): string | undefined => ir.sequenceFlows.find((f) => f.id === 'F2')?.conditionExpression; + const XSI = 'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'; - 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, - ); - }); + const boundaryConditionDoc = (conditionAttrs: string): string => + operatonDoc` + + + + amount > 1000 + + + + + `; + + const TEXPRESSION = + 'note: \'BpmnParse.parseConditionExpression\' fails the deployment on it ("Invalid type, only tFormalExpression is currently supported")'; it.each([ - ['xsi:type="bpmn:tExpression"', 'bpmn:tExpression'], [ - 'an unprefixed xsi:type="tExpression" in a bpmn:-prefixed document, which moddle drops before any reader sees it', - 'tExpression', + 'an unprefixed xsi:type dropped by moddle still reads language and resource, since parseConditionExpression resolves it against BPMN20_NS regardless', + flowDoc( + `xsi:type="tFormalExpression" ${XSI} language="groovy" operaton:resource="deployment://check.groovy"`, + ), + 'F2', + 'it declares \'language="groovy"\' with \'operaton:resource="deployment://check.groovy"\'', + 'note: Operaton runs that deployed script rather than the body written here', ], - ])( - '%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'); - }, - ); + [ + 'xsi:type="bpmn:tExpression" fails the deployment', + flowDoc(`xsi:type="bpmn:tExpression" ${XSI}`), + 'F2', + 'it is typed \'xsi:type="bpmn:tExpression"\'', + TEXPRESSION, + ], + [ + 'an unprefixed xsi:type="tExpression" in a bpmn:-prefixed document, which moddle drops before any reader sees it, fails the deployment', + flowDoc(`xsi:type="tExpression" ${XSI}`), + 'F2', + 'it is typed \'xsi:type="tExpression"\'', + TEXPRESSION, + ], + [ + 'a language on a boundary conditional event hands it to a script engine', + boundaryConditionDoc('language="groovy"'), + 'Owner', + 'it declares \'language="groovy"\'', + 'note: Operaton runs it in a script engine rather than evaluating it as UEL', + ], + ])('%s, and refuses', async (_title, xml, elementId, detail, note) => { + const e = await expectRefusal( + xmlToIr(xml), + UnsupportedConditionExpressionError, + detail, + ); + expect([e.elementId, e.message]).toEqual([ + elementId, + [ + `condition on '${elementId}' cannot be imported: ${detail}`, + note, + "note: BPMNscript writes a condition as a 'tFormalExpression', which Operaton evaluates as UEL", + 'note: a condition of another type, or one Operaton hands to a script engine, has no spelling in BPMNscript', + ].join('\n'), + ]); + }); + + it('a lone operaton:resource on a boundary conditional event warns that it takes effect only beside a language', async () => { + const { warnings } = await xmlToIr( + boundaryConditionDoc('operaton:resource="deployment://check.groovy"'), + ); + expect(warnings).toEqual([ + { + elementId: 'Owner', + category: 'extensionAttribute', + message: + "setting 'operaton:resource' on 'Owner' was not imported\n" + + "note: it only takes effect alongside a 'language' attribute\n" + + 'note: on its own the condition runs as the expression written in the body', + }, + ]); + }); + + const EMPTY_BODY = + "condition on 'F2' has an empty body and was dropped\n" + + 'note: \'UelExpressionCondition.evaluate\' reads it as a string and fails the flow on every run ("condition expression returns non-Boolean")\n' + + 'note: the flow was imported with no condition'; it.each([ [ - 'an unprefixed xsi:type="tFormalExpression" in a bpmn:-prefixed document, which moddle drops and parseConditionExpression resolves against BPMN20_NS', - flowDoc( - 'xsi:type="tFormalExpression" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"', - ), + 'an unprefixed xsi:type="tFormalExpression" in a bpmn:-prefixed document, which moddle drops and parseConditionExpression resolves against BPMN20_NS, imports as the formal expression the engine reads', + flowDoc(`xsi:type="tFormalExpression" ${XSI}`), + '${amount > 1000}', + undefined, ], [ - 'an unprefixed xsi:type="tFormalExpression" in a default-namespace document', + 'an unprefixed xsi:type="tFormalExpression" in a default-namespace document imports as the formal expression the engine reads', ` - + @@ -1646,58 +1972,39 @@ describe('xmlToIr: a scripted condition on a sequence flow or a conditional even `, + '${amount > 1000}', + undefined, ], - ])( - '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([ [ - 'a body with no "${" or "#{" opener', - 'bpmn:tFormalExpression', - 'amount > 1000', + 'a body with no "${" or "#{" opener is rewrapped, since the engine fails the flow on every run', + flowDoc(`xsi:type="bpmn:tFormalExpression" ${XSI}`, '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.', + "condition on 'F2' is the bare text 'amount > 1000' and is rewritten as an expression\n" + + "note: it has no '${...}' or '#{...}' opener\n" + + 'note: \'UelExpressionCondition.evaluate\' reads it as a string and fails the flow on every run ("condition expression returns non-Boolean")\n' + + "note: 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', - ' ', + 'an empty body is dropped, since the engine fails the flow on every run', + flowDoc(`xsi:type="bpmn:tFormalExpression" ${XSI}`, ''), 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 }, - ]); - }, - ); + [ + 'a whitespace-only body recovered past moddle, as moddle imports one, is dropped the same', + flowDoc(`xsi:type="tFormalExpression" ${XSI}`, ' '), + undefined, + EMPTY_BODY, + ], + ])('%s', async (_title, xml, imported, message) => { + const { ir, warnings } = await xmlToIr(xml); + expect(conditionOf(ir)).toBe(imported); + expect(warnings).toEqual( + message === undefined + ? [] + : [{ elementId: 'F2', category: 'behaviourChanged', message }], + ); + }); it.each([ [ @@ -1709,9 +2016,9 @@ describe('xmlToIr: a scripted condition on a sequence flow or a conditional even 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").', + "name 'yes' on the flow 'F2' was not imported\n" + + 'note: the script has no label for a flow\n' + + "note: the rebuilt document names this flow by its condition ('amount > 1000')", }, ], ], @@ -1724,9 +2031,9 @@ describe('xmlToIr: a scripted condition on a sequence flow or a conditional even 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.', + "name 'no' on the flow 'F3' was not imported\n" + + 'note: the script has no label for a flow\n' + + 'note: the rebuilt document leaves this flow unnamed', }, ], ], @@ -1734,75 +2041,11 @@ describe('xmlToIr: a scripted condition on a sequence flow or a conditional even '%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, - ), + flowDoc('', '${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', () => { @@ -1831,7 +2074,12 @@ describe('xmlToIr: warns for dropped extension attributes', () => { it.each([ ['an authored history time to live is kept', 'P90D', 'P90D', []], - ['the exported default reads back as absent', 'P30D', undefined, []], + [ + 'the exported default is kept like any authored value', + HISTORY_TIME_TO_LIVE, + HISTORY_TIME_TO_LIVE, + [], + ], [ 'an absent history time to live warns that the rebuilt process deploys', undefined, @@ -1841,11 +2089,9 @@ describe('xmlToIr: warns for dropped extension attributes', () => { 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.', + "process 'p' sets no 'historyTimeToLive' and is written back with 'P30D'\n" + + "note: 'HistoryTimeToLiveParser.parseAndValidate' refuses a process without it under the engine's default 'enforceHistoryTimeToLive'\n" + + 'note: the rebuilt process deploys where the original did not', }, ], ], @@ -1907,12 +2153,9 @@ ${children} [ '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.', + "'operaton:potentialStarter' on 'p' imports as 'candidateStarterUsers: \"a,b\"' and 'candidateStarterGroups: \"g,h\"'\n" + + "note: Operaton reads its formal expression that way ('BpmnParse.parsePotentialStarterResourceAssignment')\n" + + "note: BPMNscript writes it back as 'operaton:candidateStarterUsers' and 'operaton:candidateStarterGroups', which the engine reads the same", ], ], }, @@ -1926,9 +2169,8 @@ ${children} [ 'unmappedConstruct', 'p', - "The operaton:potentialStarter on 'p' was not imported: it carries " + - 'no formal expression, and Operaton reads nothing else off it ' + - '(BpmnParse.parsePotentialStarterResourceAssignment).', + "'operaton:potentialStarter' on 'p' was not imported\n" + + "note: it carries no formal expression, and Operaton reads nothing else off it ('BpmnParse.parsePotentialStarterResourceAssignment')", ], ], }, @@ -1978,13 +2220,9 @@ ${children} 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.', + "setting 'operaton:failedJobRetryTimeCycle' on 'Work' was not imported\n" + + "note: Operaton reads a retry cycle as an 'operaton:failedJobRetryTimeCycle' element and never as an attribute ('DefaultFailedJobParseListener.setFailedJobRetryTimeCycleValue' through 'BpmnParseUtil.findOperatonExtensionElement')\n" + + 'note: the rebuilt document runs the same', }, ], }, @@ -1994,18 +2232,18 @@ ${children} xml: signalDoc('intermediateThrowEvent', 'operaton:async="true"'), warnings: [ unread( - "'operaton:async' setting", + "setting 'operaton:async'", 'Ev', - 'a ', + "a 'bpmn:signalEventDefinition'", 'reads it on a thrown signal as its async delivery ' + - '(BpmnParse.parseSignalEventDefinition)', + "('BpmnParse.parseSignalEventDefinition')", ), unread( - "operaton:in 'a'", + "'operaton:in' named 'a'", 'Ev', - 'a ', + "a 'bpmn:signalEventDefinition'", 'reads it on a thrown signal as its payload ' + - '(BpmnParse.parseSignalEventDefinition through parseInputParameter)', + "('BpmnParse.parseSignalEventDefinition' through 'parseInputParameter')", ), ], }, @@ -2014,7 +2252,11 @@ ${children} "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 '), + unread( + "'operaton:in' named 'a'", + 'Ev', + "a 'bpmn:signalEventDefinition'", + ), ], }, { @@ -2023,41 +2265,39 @@ ${children} xml: headerDoc('operaton:jobPriority="3" operaton:taskPriority="4"'), warnings: [ unread( - "'operaton:jobPriority' setting", + "setting 'operaton:jobPriority'", 'p', - 'a ', - 'reads it (BpmnParse.parseProcess through parsePriority)', + "a 'bpmn:process'", + "reads it ('BpmnParse.parseProcess' through 'parsePriority')", ), unread( - "'operaton:taskPriority' setting", + "setting 'operaton:taskPriority'", 'p', - 'a ', - 'reads it (BpmnParse.parseProcess through parsePriority)', + "a 'bpmn:process'", + "reads it ('BpmnParse.parseProcess' through 'parsePriority')", ), ], }, + { + title: + "a process's own start printing no statement says its initiator went with it", + xml: headerDoc('', 'operaton:initiator="claimant"', 'StartEvent_p'), + warnings: [ + { + elementId: 'StartEvent_p', + category: 'extensionAttribute', + message: + "'operaton:initiator' setting on 'StartEvent_p' was not written to the script\n" + + "note: 'StartEvent_p' is the kind of name BPMNscript generates for itself, which a script cannot repeat\n" + + 'note: this start is left out entirely, and its initiator with it\n' + + 'help: rename it in the diagram to keep the initiator', + }, + ], + }, ])('$title', async ({ xml, warnings: expected }) => { const { warnings } = await xmlToIr(xml); expect(warnings).toEqual(expected); }); - - 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', () => { @@ -2074,14 +2314,16 @@ describe('xmlToIr: warns for dropped lanes', () => { `; const { warnings } = await xmlToIr(xml); - const laneWarnings = warnings.filter( - (w: ImportWarning) => w.category === 'lane', + expect(warnings).toEqual( + [ + ['Lane_Outer', 'Operations'], + ['Lane_Inner', 'Dispatch'], + ].map(([id, name]) => ({ + elementId: id, + category: 'lane', + message: `lane '${name}' (${id}) was not imported\nnote: every step is placed in a single flat process`, + })), ); - expect(laneWarnings.map((w) => w.elementId)).toEqual([ - 'Lane_Outer', - 'Lane_Inner', - ]); - expect(laneWarnings[1].message).toContain('Dispatch'); }); }); @@ -2103,11 +2345,15 @@ describe('xmlToIr: undeclared operaton extension element residual', () => { it('reports the undeclared element once (no silent loss) without flagging the clean task', async () => { const { warnings } = await xmlToIr(residualXml); // Moddle ties a residual drop to no step, so it lands on the process. - expectOneWarning(extensionWarnings(warnings), { - elementId: 'p', - message: /formProperty/i, - }); - expect(warnings.some((w) => w.elementId === 'CleanTask')).toBe(false); + expect(warnings).toEqual([ + { + elementId: 'p', + category: 'extensionAttribute', + message: + 'engine-specific configuration (operaton:formProperty at line 11) was not imported\n' + + 'note: it could not be attributed to a specific step', + }, + ]); }); }); @@ -2130,7 +2376,7 @@ describe('xmlToIr: bpmn:documentation', () => { 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.`, + message: `documentation on '${id}' was not imported\nnote: ${position} has no documentation in BPMNscript`, }); const TIMER = @@ -2230,13 +2476,14 @@ ${KINDS.filter(([tag]) => tag.endsWith('Gateway')) elementId: 'Msg', category: 'unreferencedRoot', message: - 'The message "Paid" declared by root \'Msg\' is never used by an on/throw/emit; it was not imported.', + "message 'Paid' declared by root 'Msg' was not imported\n" + + "note: no 'on', 'throw' or 'emit' uses it", }, 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'), + dropped('Err', "a 'bpmn:error' root"), + dropped('Esc', "a 'bpmn:escalation' root"), + dropped('Msg', "a 'bpmn:message' root"), + dropped('Sig', "a 'bpmn:signal' root"), ]); }); @@ -2253,12 +2500,12 @@ ${KINDS.filter(([tag]) => tag.endsWith('Gateway')) expect(warnings).toEqual([ warning( 'StartEvent_p', - /this start is left out entirely and its documentation with it/, + "documentation on 'StartEvent_p' was not written to the script\nnote: 'StartEvent_p' is the kind of name BPMNscript generates for itself, which a script cannot repeat\nnote: this start is left out entirely, and its documentation with it\nhelp: rename it in the diagram to keep the documentation", 'documentation', ), warning( 'EndEvent_p', - /Where the script can do without this end, it is left out and its documentation with it/, + "documentation on 'EndEvent_p' cannot be kept as written\nnote: 'EndEvent_p' is the kind of name BPMNscript generates for itself, which a script cannot repeat\nnote: where the script can do without this end, it is left out, and its documentation with it\nnote: anywhere else it prints under that name and is refused when read back\nhelp: rename it in the diagram to keep the documentation", 'documentation', ), ]); @@ -2268,12 +2515,12 @@ ${KINDS.filter(([tag]) => tag.endsWith('Gateway')) [ 'a second documentation child leaves the element with none', `${doc('The first.')}${doc('The second.')}`, - /this surface holds one and 'T' carries 2/, + "documentation on 'T' was not imported\nnote: BPMNscript holds one 'bpmn:documentation' 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'/, + "documentation on 'T' was not imported\nnote: BPMNscript holds plain text and its 'textFormat' is 'text/html'", ], ])('%s', async (_title, children, detail) => { const { ir, warnings } = await xmlToIr( @@ -2302,7 +2549,14 @@ describe('xmlToIr: warns for unmapped BPMN content', () => { [ 'content on bpmn:definitions itself is reported against the process', withRoots(' '), - [warning('p', /bpmn:extension/, 'unmappedConstruct')], + [ + warning( + 'p', + "element 'bpmn:extension' on 'p' was not imported\n" + + 'note: BPMNscript imports the executable flow and the engine settings on its steps, and nothing declared or drawn beside it', + 'unmappedConstruct', + ), + ], ], [ 'a dataObject with a dataState child at process level is reported once, whole', @@ -2315,9 +2569,8 @@ describe('xmlToIr: warns for unmapped BPMN content', () => { elementId: 'Data1', category: 'unmappedConstruct', 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.', + "'bpmn:dataObject' element 'Data1' was not imported\n" + + 'note: Operaton keeps process variables in its own store and never dispatches on it, so the imported process runs the same', }, ], ], @@ -2331,9 +2584,8 @@ describe('xmlToIr: warns for unmapped BPMN content', () => { elementId: 'G3', category: 'unmappedConstruct', message: - "A bpmn:globalScriptTask 'G3' root element was not imported " + - '(this tool imports the executable flow and the engine settings on ' + - 'its steps, and nothing declared or drawn beside it).', + "root 'bpmn:globalScriptTask' element 'G3' was not imported\n" + + 'note: BPMNscript imports the executable flow and the engine settings on its steps, and nothing declared or drawn beside it', }, ], ], @@ -2348,53 +2600,34 @@ describe('xmlToIr: warns for unmapped BPMN content', () => { elementId: 'S', category: 'unmappedConstruct', message: - "The eventDefinitionRef 'TED' on 'S' was not imported: BpmnParse " + - 'reads only the event definitions nested in the event, so the ' + - 'document written back runs the same.', + "'eventDefinitionRef' 'TED' on 'S' was not imported\n" + + "note: 'BpmnParse' reads only the event definitions nested in the event, so the rebuilt document runs the same", }, { elementId: 'TED', category: 'unmappedConstruct', message: - "A bpmn:terminateEventDefinition 'TED' root element was not " + - 'imported (this tool imports the executable flow and the engine ' + - 'settings on its steps, and nothing declared or drawn beside it).', + "root 'bpmn:terminateEventDefinition' element 'TED' was not imported\n" + + 'note: BPMNscript imports the executable flow and the engine settings on its steps, and nothing declared or drawn beside it', }, ], ], [ 'isExecutable="false" is reported, and the process imports as executable regardless', withRoots('', '', 'isExecutable="false"'), - [warning('p', /isExecutable="false".*deploy/i, 'behaviourChanged')], + [ + warning( + 'p', + "process 'p' was imported as executable although it is marked 'isExecutable=\"false\"'\nnote: BPMNscript cannot express a process that is not executable\nnote: it is written back as executable, so an engine deploys and runs what the original document held back", + 'behaviourChanged', + ), + ], ], ])('%s', async (_title, xml, expected) => { const { ir, warnings } = await xmlToIr(xml); expect(ir.isExecutable).toBe(true); expect(warnings).toEqual(expected); }); - - it('a trigger on a start event nested inside a sub-process refuses', async () => { - const e = await expectRefusal( - xmlToIr( - oneNodeDoc('subProcess', { - id: 'Sub', - doc: bpmnDoc, - children: ` - - - - `, - }), - ), - UnsupportedEventFeatureError, - 'a subprocess cannot start on a trigger: Operaton rejects one there ' + - 'when it parses the file; a subprocess is entered from the ' + - 'surrounding process, not by an event of its own', - ); - expect(e.elementId).toBe('SubStart'); - expect(e.message).toContain("Put the trigger on an 'on' handler"); - expect(e.message).not.toContain('Event handlers catch one'); - }); }); describe('xmlToIr: callActivity import', () => { @@ -2452,13 +2685,13 @@ describe('xmlToIr: callActivity import', () => { 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}.`, + `'${attr}' on an 'operaton:in' of 'CallSub' was not imported\n` + + `note: it has no effect alongside '${winner}', since ${why}`, ); const PROVIDER_READS_ALL = - 'BpmnParse.parseCallableElementProvider passes every variable and reads nothing else'; + "'BpmnParse.parseCallableElementProvider' passes every variable and reads nothing else"; const KEY_ALONE = - 'BpmnParse.parseInputParameter reads the business key alone off that element'; + "'BpmnParse.parseInputParameter' reads the business key alone off that element"; it.each([ { @@ -2468,11 +2701,9 @@ describe('xmlToIr: callActivity import', () => { 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.', + "'calledElementBinding=\"bogus\"' on 'CallSub' imports as 'binding: latest'\n" + + "note: 'BpmnParse.parseBinding' sets no binding for that word, and 'BaseCallableElement.isLatestBinding' reads none as latest\n" + + 'note: BPMNscript writes it back as \'calledElementBinding="latest"\', which the engine reads the same', 'rewritten', ), CAMUNDA_ALIAS_WARNING, @@ -2499,9 +2730,8 @@ describe('xmlToIr: callActivity import', () => { 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.', + "setting 'variableMappingDelegateExpression' on 'CallSub' was not imported\n" + + "note: it has no effect alongside 'operaton:variableMappingClass'", ), ], }, @@ -2516,7 +2746,7 @@ describe('xmlToIr: callActivity import', () => { inIgnored( 'sourceExpression', 'source', - 'BpmnParse.parseCallableElementProvider reads source first', + "'BpmnParse.parseCallableElementProvider' reads 'source' first", ), ], }, @@ -2553,10 +2783,9 @@ describe('xmlToIr: callActivity import', () => { 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.', + "'operaton:in businessKey=\"${a}\"' on 'CallSub' was not imported\n" + + 'note: it has no effect alongside a later one\n' + + "note: 'BpmnParse.parseInputParameter' hands each to 'setBusinessKeyValueProvider', and the last stands", ), ], }, @@ -2575,19 +2804,17 @@ describe('xmlToIr: callActivity import', () => { [ 'calledElementBinding="version" without a version', callDoc('operaton:calledElementBinding="version"'), - 'calledElementBinding="version" is set without a calledElementVersion, ' + - 'so the engine cannot resolve which version to use', + "'calledElementBinding=\"version\"' is set without a 'calledElementVersion'", ], [ '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', + '\'calledElementBinding="versionTag"\' pins a version tag', ], [ 'operaton:calledElementTenantId', callDoc('operaton:calledElementTenantId="tenant-a"'), - /calledElementTenantId/, + 'it names \'operaton:calledElementTenantId="tenant-a"\'', ], [ 'a caseRef and no calledElement', @@ -2595,54 +2822,122 @@ describe('xmlToIr: callActivity import', () => { 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', + "it names 'operaton:caseRef=\"claims\"' and no 'calledElement'", ], [ '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'\")", + "it names neither a 'calledElement' nor an 'operaton:caseRef'", ], [ 'a calledElement beside a caseRef', callDoc('operaton:caseRef="claims"'), - 'it names a calledElement beside operaton:caseRef="claims", which ' + - 'BpmnParse.parseCallActivity refuses to deploy ("The attributes ' + - "'calledElement' or 'caseRef' cannot be used together\")", + "it names a 'calledElement' beside 'operaton:caseRef=\"claims\"'", ], [ 'an operaton:in with source but no target', callDoc('', ''), - 'an operaton:in carries source without a target', + "an 'operaton:in' carries 'source' without a 'target'", ], [ 'an operaton:in with variables="foo"', callDoc('', ''), - 'an operaton:in carries variables="foo", which this tool cannot import (only variables="all" is supported)', + "an 'operaton:in' carries a variables value other than all ('variables=\"foo\"')", ], [ 'an empty operaton:in', callDoc('', ''), - 'an operaton:in carries none of the recognized shapes (source+target, sourceExpression+target, variables="all", or businessKey)', + "an 'operaton:in' carries none of the recognized shapes", ], [ 'an operaton:in with an empty source, quoting the strict validation', callDoc('', ''), - 'an operaton:in carries source="", which BpmnParse.parseCallableElementProvider refuses to deploy ("Empty attribute \'source\' when passing variables")', + "an 'operaton:in' carries an empty source ('source=\"\"')", ], [ 'an operaton:in with sourceExpression but no target', callDoc('', ''), - 'an operaton:in carries sourceExpression without a target', + "an 'operaton:in' carries 'sourceExpression' without a 'target'", ], ])('a call activity with %s is refused', async (_title, xml, detail) => { await expectRefusal(xmlToIr(xml), UnsupportedCallActivityError, detail); }); + + // The script writes a mapped variable as a plain name, which has no + // backtick form. + it.each([ + [ + 'an operaton:out target', + '', + "its 'operaton:out' target", + 'var', + ], + [ + 'an operaton:in source', + '', + "its 'operaton:in' source", + 'end', + ], + [ + 'an operaton:in target', + '', + "its 'operaton:in' target", + 'a b', + ], + ])( + 'a call activity mapping %s no plain name spells is refused', + async (_title, mapping, attribute, name) => { + const e = await expectRefusal( + xmlToIr(callDoc('', mapping)), + UnsupportedVariableNameError, + `${attribute} names the variable '${name}', which BPMNscript cannot write back unchanged`, + ); + expect(e.message.split('\n').slice(1)).toEqual([ + 'note: BPMNscript writes a variable as a plain name, and this name is not one', + "note: a plain name is letters, digits and '_', with '-' between them, and no keyword", + ]); + }, + ); }); +// The engine stores the caught value under any name, but the script writes a +// catch binding as an identifier. +it.each([ + ['error code', 'errorEventDefinition', 'errorCodeVariable', 'code', '208'], + [ + 'error message', + 'errorEventDefinition', + 'errorMessageVariable', + 'message', + 'var', + ], + [ + 'escalation code', + 'escalationEventDefinition', + 'escalationCodeVariable', + 'code', + 'a b', + ], +] as const)( + 'a caught %s stored in a variable no plain name spells is refused', + async (_title, definition, attr, caught, name) => { + const xml = rootedDoc( + '', + ` + `, + ); + const e = await expectRefusal( + xmlToIr(xml), + UnsupportedEventFeatureError, + `it stores the caught ${caught} in '${name}', which BPMNscript cannot write back unchanged`, + ); + expect(e.message.split('\n').slice(1)).toEqual([ + `note: 'operaton:${attr}' names a variable, which BPMNscript writes as a plain name, and this name is not one`, + "note: a plain name is letters, digits and '_', with '-' between them, and no keyword", + ]); + }, +); + describe('xmlToIr: event layer import', () => { const fullEventXml = dualDefs` @@ -2681,6 +2976,7 @@ describe('xmlToIr: event layer import', () => { const EXPECTED_EVENT_IR: BpmnProcess = { id: 'p', isExecutable: true, + historyTimeToLive: HISTORY_TIME_TO_LIVE, errorDecls: [{ name: 'PF', code: 'PF', message: 'boom' }], escalationDecls: [{ name: 'LS', code: 'LS' }], flowElements: [ @@ -2715,6 +3011,61 @@ describe('xmlToIr: event layer import', () => { }); describe('refusals', () => { + const wiredHandler = (flow: string): string => + bpmnDoc` + + + + + + + + + + ${flow}`; + + const workBoundaryDoc = (boundary: string): string => + rootedDoc( + '', + ` + + + + + + ${boundary} + + + + + + `, + ); + + const throwDoc = (definition: string, roots = ''): string => + rootedDoc( + roots, + ` + ${definition} + + `, + ); + + const rootsOnly = (roots: string): string => + rootedDoc( + roots, + ` + `, + operatonDefs, + ); + + const DANGLING_ESCALATION = + "its escalation reference ('escalationRef=\"Esc_Missing\"') names no 'bpmn:escalation' root"; + const missingEscalation = (method: string): string => + `note: Operaton refuses to deploy it ("could not find escalation with id 'Esc_Missing'", 'BpmnParse.${method}')`; + const UNWIRED_HELP = + "help: take the flow 'F2' off, since the handler runs when its trigger fires"; + it.each([ [ 'an event handler with zero start events', @@ -2724,6 +3075,9 @@ describe('xmlToIr: event layer import', () => { `, + 'Handler', + 'an event handler must have exactly one start event carrying its trigger (found 0)', + [HANDLER_TRIGGER], ], [ 'a handler start with two event definitions', @@ -2732,6 +3086,9 @@ describe('xmlToIr: event layer import', () => { `, { body: '' }, ), + 'Handler', + 'its start event must carry exactly one trigger definition (found 2)', + [HANDLER_TRIGGER], ], [ 'isInterrupting="false" on an error handler', @@ -2739,6 +3096,35 @@ describe('xmlToIr: event layer import', () => { roots: ' \n', startAttrs: 'isInterrupting="false"', }), + 'Handler', + 'an error handler cannot be non-interrupting (\'isInterrupting="false"\')', + ['note: BPMN requires an error trigger to interrupt its scope'], + ], + [ + 'a flow into an event handler written without incoming/outgoing children, on the flow itself', + wiredHandler( + '', + ), + 'Handler', + "the flow 'F2' enters the event handler 'Handler'", + [ + 'note: \'BpmnParse.parseSequenceFlow\' fails the deployment on it ("Invalid incoming sequence flow of event subprocess")', + 'note: a handler is entered by its trigger', + UNWIRED_HELP, + ], + ], + [ + 'a flow out of an event handler written without incoming/outgoing children, on the flow itself', + wiredHandler( + '', + ), + 'Handler', + "the flow 'F2' leaves the event handler 'Handler'", + [ + 'note: \'BpmnParse.parseSequenceFlow\' fails the deployment on it ("Invalid outgoing sequence flow of event subprocess")', + 'note: a handler ends where its body ends', + UNWIRED_HELP, + ], ], [ 'an error definition on an intermediate throw', @@ -2751,117 +3137,42 @@ describe('xmlToIr: event layer import', () => { `, ), + 'BadEmit', + 'an emit cannot carry an error', + [ + 'note: BPMN has no intermediate error throw', + "help: write 'throw error' to end the path instead", + ], ], [ 'two bpmn:Error roots sharing a code but disagreeing on the message', - rootedDoc( - ` - \n`, - ` - `, - operatonDefs, - ), + rootsOnly(` + \n`), + 'Error_B', + "error roots 'Error_A' and 'Error_B' both declare code 'DUP' but disagree about the thrown message", + [], ], [ 'a declared message on a code-less bpmn:Error root', - rootedDoc( + rootsOnly( ' \n', - ` - `, - operatonDefs, ), + 'Error_NoCode', + "this error root declares an error message but no 'errorCode'", + ['note: a declared error message needs a code to be keyed by'], ], - ])('%s refuses with UnsupportedEventFeatureError', async (_title, xml) => { - await expect(xmlToIr(xml)).rejects.toBeInstanceOf( - UnsupportedEventFeatureError, - ); - }); - - 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', - ), + DANGLING_ESCALATION, + [ + missingEscalation( + 'createEscalationEventDefinitionForEscalationHandler', + ), + ], ], [ 'an escalation end whose escalationRef names no root', @@ -2869,16 +3180,21 @@ describe('xmlToIr: event layer import', () => { '', ), 'Throw', - danglingEscalation('findEscalationForEscalationEventDefinition'), + DANGLING_ESCALATION, + [ + missingEscalation('findEscalationForEscalationEventDefinition'), + THROW_CODE, + ], ], [ '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)', + "its escalation definition carries no 'escalationRef'", + [ + "note: Operaton refuses to deploy it (\"escalationEventDefinition does not have required attribute 'escalationRef'\", 'BpmnParse.findEscalationForEscalationEventDefinition')", + THROW_CODE, + ], ], [ 'an escalation end naming a code-less root', @@ -2887,19 +3203,21 @@ describe('xmlToIr: event layer import', () => { ' \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)", + "its escalation reference names a root that carries no code ('Esc_NoCode')", + [ + "note: Operaton refuses to deploy a throw of one (\"throwing escalation event must have an 'escalationCode'\", 'BpmnParse.parseIntermediateThrowEvent', and \"escalation end event must have an 'escalationCode'\", 'parseEndEvents')", + THROW_CODE, + ], ], [ '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)', + "its error definition carries no 'errorRef'", + [ + "note: Operaton refuses to deploy it (\"'errorRef' attribute is mandatory on error end event\", 'BpmnParse.parseEndEvents')", + THROW_CODE, + ], ], [ 'an error end naming a code-less root', @@ -2908,34 +3226,14 @@ describe('xmlToIr: event layer import', () => { ' \n', ), 'Throw', - "its errorRef names the bpmn:error root 'Error_NoCode', which " + - 'carries no code; Operaton refuses to deploy the throw ' + - "(\"'errorCode' is mandatory on errors referenced by throwing " + - 'error event definitions", BpmnParse.parseEndEvents)', - ], - ])( - '%s refuses with UnsupportedEventFeatureError quoting the engine', - async (_title, xml, elementId, detail) => { - const e = await expectRefusal( - xmlToIr(xml), - UnsupportedEventFeatureError, - detail, - ); - expect(e.elementId).toBe(elementId); - }, - ); - - const danglingErrorRef = (ownerId: string): ImportWarning => ({ - elementId: ownerId, - category: '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.', + "its error reference names a root that carries no code ('Error_NoCode')", + [ + "note: Operaton refuses to deploy the throw (\"'errorCode' is mandatory on errors referenced by throwing error event definitions\", 'BpmnParse.parseEndEvents')", + THROW_CODE, + ], + ], + ])('%s refuses', async (_title, xml, elementId, detail, notes) => { + await expectEventRefusal(xml, elementId, detail, notes); }); it("an error end whose errorRef names no root imports the reference text as the code, the engine's reading, with one warning", async () => { @@ -2946,46 +3244,29 @@ describe('xmlToIr: event layer import', () => { expect('eventDefinition' in owner && owner.eventDefinition).toEqual( errorDef('Error_Missing'), ); - expect(warnings).toEqual([danglingErrorRef('Throw')]); + expect(warnings).toEqual([danglingErrorRef('Throw', 'Error_Missing')]); expect(ir.errorDecls).toBeUndefined(); expect(await irToXml(ir)).toContain( '', ); }); }); - - describe('warn-drops', () => { - const unusedRootXml = (root: string, defs = bpmnDefs) => - rootedDoc( - `${root}\n`, - ` - `, - defs, - ); - - it('an error root with no code warns and is dropped', async () => { - const { ir, warnings } = await xmlToIr( - unusedRootXml(' '), - ); - expect(ir.errorDecls).toBeUndefined(); - expect(ir.escalationDecls).toBeUndefined(); - expect( - warnings - .filter((w) => w.category === 'unreferencedRoot') - .map((w) => w.elementId), - ).toEqual(['Error_NoCode']); - }); - }); }); describe('xmlToIr: message/signal/timer/conditional import', () => { describe('refusals', () => { + const UNRESOLVED_REF = [ + 'note: BPMNscript matches the reference to a root id as written', + "note: Operaton resolves a prefixed reference through the xmlns table ('BpmnParse.resolveName') and refuses an unresolved one", + ]; + it.each([ [ 'a ref-less message definition', handlerDoc('', { body: '' }), - 'a message definition must reference a bpmn:Message root with a ' + - 'non-empty name', + 'HStart', + "a message definition must reference a 'bpmn:Message' root with a non-empty name", + [], ], [ 'a prefixed messageRef, which the engine resolves and this tool does not', @@ -2996,16 +3277,27 @@ describe('xmlToIr: message/signal/timer/conditional import', () => { body: '', }, ), - "its messageRef 'tns:Message_1' names no bpmn:Message root: this " + - 'tool matches the reference to a root id as written, where ' + - 'Operaton resolves a prefixed reference through the xmlns table ' + - '(BpmnParse.resolveName) and refuses an unresolved one', + 'HStart', + "its message reference ('messageRef=\"tns:Message_1\"') names no 'bpmn:Message' root", + UNRESOLVED_REF, + ], + [ + 'a receive task whose messageRef names no message', + rootedDoc( + '', + ` + `, + ), + 'R', + "its message reference ('messageRef=\"Missing\"') names no 'bpmn:Message' root", + UNRESOLVED_REF, ], [ 'a timer definition with zero time children', handlerDoc('', { body: '' }), - 'a timer definition must carry exactly one of ' + - 'timeDuration/timeDate/timeCycle (found 0)', + 'HStart', + "a timer definition must carry exactly one of 'timeDuration', 'timeDate' and 'timeCycle' (found 0)", + [], ], [ 'a timer definition with an empty body', @@ -3015,13 +3307,16 @@ describe('xmlToIr: message/signal/timer/conditional import', () => { `, { body: '' }, ), - "a timer definition's timeDuration has an empty body", + 'HStart', + "the 'timeDuration' of a timer definition has an empty body", + [], ], [ 'a conditional definition without a condition child', handlerDoc('', { body: '' }), - 'a conditional definition must carry a condition with a non-empty ' + - 'body', + 'HStart', + 'a conditional definition must carry a condition with a non-empty body', + [], ], [ 'operaton:variableName on a conditional definition', @@ -3031,13 +3326,16 @@ describe('xmlToIr: message/signal/timer/conditional import', () => { `, { body: '', defs: operatonDefs }, ), - "a conditional definition's operaton:variableName narrows when the " + - 'condition is (re-)evaluated, which this tool cannot represent', + 'HStart', + "a conditional definition sets 'operaton:variableName'", + [ + 'note: it narrows when the condition is evaluated again, which BPMNscript cannot represent', + ], ], ] as const)( '%s refuses with UnsupportedEventFeatureError naming the form', - async (_title, xml, detail) => { - await expectRefusal(xmlToIr(xml), UnsupportedEventFeatureError, detail); + async (_title, xml, elementId, detail, notes) => { + await expectEventRefusal(xml, elementId, detail, notes); }, ); @@ -3053,9 +3351,8 @@ describe('xmlToIr: message/signal/timer/conditional import', () => { ), 'end', 'bpmn:LinkEventDefinition', - 'A typed end event supports terminate, error, escalation, message, ' + - 'signal, or compensation, plus cancel inside a block that can be ' + - 'given up.', + "end event 'E' carries an unsupported link definition 'bpmn:LinkEventDefinition'\n" + + "note: a typed end event supports 'terminate', 'error', 'escalation', 'message', 'signal', or 'compensation', plus 'cancel' inside a block that can be given up", ], [ 'a conditional definition on an intermediate throw', @@ -3068,7 +3365,37 @@ describe('xmlToIr: message/signal/timer/conditional import', () => { }), 'intermediate throw', 'bpmn:ConditionalEventDefinition', - 'An emit supports escalation, message, signal, compensation, or link.', + "intermediate throw event 'Emit' carries an unsupported conditional definition 'bpmn:ConditionalEventDefinition'\n" + + "note: an emit supports 'escalation', 'message', 'signal', 'compensation', or 'link'", + ], + [ + 'a link definition on a boundary event', + rootedDoc( + '', + ` + + + + `, + ), + 'boundary', + 'bpmn:LinkEventDefinition', + "boundary event 'Boundary_Review_link' carries an unsupported link definition 'bpmn:LinkEventDefinition'\n" + + "note: a boundary event supports 'error', 'escalation', 'message', 'signal', 'timer', or 'condition', plus 'cancel' on a block that can be given up", + ], + [ + 'the abstract bpmn:eventDefinition tag on a start, named a "special" definition', + bpmnDoc` + + + + `, + 'start', + 'bpmn:EventDefinition', + "start event 'S' carries an unsupported special definition 'bpmn:EventDefinition'\n" + + 'note: a plain start event carries no definition\n' + + "note: a process's start supports 'message', 'signal', 'timer', or 'condition'\n" + + "note: an event handler's start supports 'error', 'escalation', 'message', 'signal', 'timer', 'condition', or 'compensation'", ], ] as const)( '%s refuses with UnsupportedEventDefinitionError naming what the position does take', @@ -3077,9 +3404,11 @@ describe('xmlToIr: message/signal/timer/conditional import', () => { xmlToIr(xml), UnsupportedEventDefinitionError, ); - expect(e.eventKind).toBe(eventKind); - expect(e.definitionType).toBe(definitionType); - expect(e.message).toContain(supported); + expect([e.eventKind, e.definitionType, e.message]).toEqual([ + eventKind, + definitionType, + supported, + ]); }, ); }); @@ -3100,20 +3429,29 @@ describe('xmlToIr: message/signal/timer/conditional import', () => { messageDef('X'), ); - expect(warnings).toHaveLength(2); - const [itemRefWarning, rootWarning] = warnings; - expect(itemRefWarning.elementId).toBe('Message_X'); - expect(itemRefWarning.message).toContain('itemRef'); - expect(rootWarning.elementId).toBe('Item_1'); - expect(rootWarning.message).toContain("bpmn:itemDefinition 'Item_1'"); + expect(warnings).toEqual([ + { + elementId: 'Message_X', + category: 'extensionAttribute', + message: + "setting 'itemRef' on message root 'Message_X' was not imported\n" + + 'note: it names a data structure Operaton does not execute', + }, + { + elementId: 'Item_1', + category: 'unmappedConstruct', + message: + "root 'bpmn:itemDefinition' element 'Item_1' was not imported\n" + + 'note: BPMNscript imports the executable flow and the engine settings on its steps, and nothing declared or drawn beside it', + }, + ]); }); }); }); describe('xmlToIr: compensation import', () => { - describe('refusals', () => { - const undoHandler = (startAttrs = '', definitionAttrs = '') => - ` + const undoHandler = (startAttrs = '', definitionAttrs = '') => + ` @@ -3121,65 +3459,90 @@ describe('xmlToIr: compensation import', () => { `; - const bookingDoc = (body: string) => - oneNodeDoc('subProcess', { - id: 'Booking', - doc: bpmnDoc, - children: ` + const bookingDoc = (body: string) => + oneNodeDoc('subProcess', { + id: 'Booking', + doc: bpmnDoc, + children: ` ${body} `, - }); + }); - it.each([ - [ - 'an activityRef on a compensation handler-start definition', - bookingDoc( - ` + const BOUNDARY = ` + + `; + const HANDLER = + ''; + const RESERVE = + ''; + + const reserveDoc = (nodes: string, association = ''): string => + operatonDoc` + ${nodes} + + + + ${association}`; + const association = (from: string, to: string): string => + ``; + + const HOSTED = [ + 'note: a compensation handler must be hosted directly by the block whose completed work it undoes', + 'help: move it inside that block', + ]; + const BOUNDARY_REFUSED = [ + "note: BPMN attaches compensation through 'isForCompensation' and a 'bpmn:association' on the activity being compensated, not a boundary event", + "help: wrap the steps in their own subprocess and target it with 'on compensation'", + ]; + + it.each([ + [ + 'an activityRef on a compensation handler-start definition', + bookingDoc( + ` ${undoHandler('', 'activityRef="ReserveRoom"')}`, - ), - 'a compensation definition targets one activity by reference ' + - '(activityRef="ReserveRoom"); this tool always addresses the ' + - 'enclosing scope and cannot target a single activity', - ], + ), + 'UndoStart', + 'a compensation definition targets one activity by reference (\'activityRef="ReserveRoom"\')', [ - 'waitForCompletion="false" on an intermediate throw', - oneNodeDoc('intermediateThrowEvent', { - id: 'EmitUndo', - doc: bpmnDoc, - children: - '', - }), - 'a compensation definition sets waitForCompletion="false"; this ' + - 'tool only imports the default (wait for the compensation to ' + - 'complete) behavior', + 'note: BPMNscript always addresses the enclosing scope and cannot target a single activity', ], + ], + [ + 'waitForCompletion="false" on an intermediate throw', + oneNodeDoc('intermediateThrowEvent', { + id: 'EmitUndo', + doc: bpmnDoc, + children: + '', + }), + 'EmitUndo', + 'a compensation definition sets \'waitForCompletion="false"\'', [ - 'isInterrupting="false" on a compensation handler start', - bookingDoc(undoHandler('isInterrupting="false"')), - 'a compensation handler cannot be non-interrupting ' + - '(isInterrupting="false"); BPMN requires a compensation trigger ' + - 'to interrupt its scope', + 'note: BPMNscript only imports the default behaviour, which waits for the compensation to complete', ], - ] as const)( - '%s refuses with UnsupportedEventFeatureError naming the feature', - async (_title, xml, detail) => { - await expectRefusal(xmlToIr(xml), UnsupportedEventFeatureError, detail); - }, - ); - - it.each([ - [ - 'a compensation event sub-process hosted directly by the process', - bpmnDoc` + ], + [ + 'isInterrupting="false" on a compensation handler start', + bookingDoc(undoHandler('isInterrupting="false"')), + 'UndoBooking', + 'a compensation handler cannot be non-interrupting (\'isInterrupting="false"\')', + ['note: BPMN requires a compensation trigger to interrupt its scope'], + ], + [ + 'a compensation event sub-process hosted directly by the process', + bpmnDoc` ${undoHandler()} `, - 'the process', - ], - [ - 'a compensation event sub-process hosted by another event sub-process', - bpmnDoc` + 'UndoBooking', + 'a compensation handler is hosted by the process', + HOSTED, + ], + [ + 'a compensation event sub-process hosted by another event sub-process', + bpmnDoc` @@ -3190,125 +3553,130 @@ describe('xmlToIr: compensation import', () => { `, - '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([ - [ - 'no bpmn:association leaves the boundary event at all', - operatonDoc` - - - + 'UndoBooking', + 'a compensation handler is hosted by another event subprocess', + HOSTED, + ], + [ + 'a compensation boundary event no bpmn:association leaves', + reserveDoc(`${RESERVE}\n ${BOUNDARY}`), + 'CompensationBoundary', + 'a compensation boundary event is not imported', + BOUNDARY_REFUSED, + ], + [ + 'a compensation boundary event whose bpmn:association targets a plain task that never declared isForCompensation', + reserveDoc( + `${RESERVE}\n ${BOUNDARY}\n `, + association('CompensationBoundary', 'NotAHandler'), + ), + 'CompensationBoundary', + 'a compensation boundary event is not imported', + BOUNDARY_REFUSED, + ], + [ + // Boundary first: only the rewrite preview's mapper meets the host before the container walk. + 'a paired compensation whose host carries content this tool cannot import at all, with the compensation refusal and not that unrelated one', + reserveDoc( + `${BOUNDARY} + + ${HANDLER}`, + association('CompensationBoundary', 'CancelReservation'), + ), + 'CompensationBoundary', + 'a compensation boundary event is not imported', + BOUNDARY_REFUSED, + ], + [ + 'a timer boundary associated with an isForCompensation handler, which is not the paired pattern', + reserveDoc( + `${RESERVE} + + + PT1H + - - - `, - ], + ${HANDLER}`, + association('TimerBoundary', 'CancelReservation'), + ), + 'CancelReservation', + IS_FOR_COMPENSATION, + EXCLUDED_FROM_FLOW, + ], + [ + 'a bpmn:association from a plain task to an isForCompensation handler, which is not the paired pattern either', + reserveDoc( + `${RESERVE}\n \n ${HANDLER}`, + association('NotABoundary', 'CancelReservation'), + ), + 'CancelReservation', + IS_FOR_COMPENSATION, + EXCLUDED_FROM_FLOW, + ], + ] as const)('%s refuses', async (_title, xml, elementId, detail, notes) => { + await expectEventRefusal(xml, elementId, detail, notes); + }); + + it.each([ + [ + 'a task host with the handler first in document order', + reserveDoc( + `${RESERVE}\n ${HANDLER}\n ${BOUNDARY}`, + association('CompensationBoundary', 'CancelReservation'), + ), + 'CancelReservation', + "'isForCompensation=\"true\"' marks 'CancelReservation' as the handler a 'bpmn:association' targets from boundary event 'CompensationBoundary' on 'ReserveRoom'", + "note: BPMN's boundary-plus-association pattern is not imported", [ - 'the bpmn:association targets a plain task that never declared isForCompensation, so it is not a handler', - operatonDoc` - - - - - - - - - `, + 'subprocess Compensated_ReserveRoom {', + ' service ReserveRoom(class: "com.example.Reserve")', + ' on compensation {', + ' user CancelReservation', + ' }', + '}', ], - ] as const)( - 'a compensation boundary event refuses with the general wording, byte-for-byte, when %s', - async (_title, xml) => { - const e = await expectRefusal( - xmlToIr(xml), - UnsupportedEventFeatureError, - COMPENSATION_BOUNDARY_DETAIL, - ); - expect(e.elementId).toBe('CompensationBoundary'); - }, - ); - - const BOUNDARY = ` - - `; - const HANDLER = - ''; - const RESERVE = - ''; - - const pairedDoc = (...nodes: readonly string[]): string => - operatonDoc` - ${nodes.join('\n ')} - - - - `; - - const REWRITE_PREVIEW = [ - 'subprocess Compensated_ReserveRoom {', - ' service ReserveRoom(class: "com.example.Reserve")', - ' on compensation {', - ' user CancelReservation', - ' }', - '}', - ]; - - it.each([ + ], + [ + 'a sub-process host, nested in the preview', + reserveDoc( + ` + + + + + + + ${BOUNDARY} + ${HANDLER}`, + association('CompensationBoundary', 'CancelReservation'), + ), + 'CompensationBoundary', + "boundary event 'CompensationBoundary' compensates 'ReserveRoom' through the handler 'CancelReservation' its 'bpmn:association' targets", + 'note: a compensation boundary event is not imported', [ - 'the handler comes first in document order', - pairedDoc(RESERVE, HANDLER, BOUNDARY), - 'CancelReservation', - REWRITE_PREVIEW, + 'subprocess Compensated_ReserveRoom {', + ' subprocess ReserveRoom {', + ' start RStart', + ' user Hold', + ' end REnd', + ' }', + ' on compensation {', + ' user CancelReservation', + ' }', + '}', ], - ] as const)( - 'a compensated activity, its boundary event and its association-linked handler are all named in the refusal, and the printed rewrite re-parses through the compiler (%s)', - async (_title, xml, elementId, preview) => { - const e = await expectRefusal( - xmlToIr(xml), - UnsupportedEventFeatureError, - ); - expect(e.elementId).toBe(elementId); - expect(e.detail).toContain('ReserveRoom'); - expect(e.detail).toContain('CompensationBoundary'); - expect(e.detail).toContain('CancelReservation'); - await expectRewrite(e, preview); - }, - ); - - it('a compensated activity that independently carries content this tool cannot import at all still raises the compensation refusal, not that unrelated one', async () => { - // Boundary first: only the rewrite preview's mapper meets the host before the container walk. - const xml = operatonDoc` - - - - - - - - - `; - - const e = await expectRefusal( - xmlToIr(xml), - UnsupportedEventFeatureError, - COMPENSATION_BOUNDARY_DETAIL, - ); - expect(e.elementId).toBe('CompensationBoundary'); - }); - }); + ], + ] as const)( + 'the paired compensation pattern on %s refuses with a rewrite preview that re-parses through the compiler', + async (_title, xml, elementId, detail, note, preview) => { + await expectEventRefusal(xml, elementId, detail, [ + note, + 'help: write it by hand instead', + ...preview.map((line) => ` ${line}`), + ]); + await expectParses(`process Preview {\n${preview.join('\n')}\n}\n`); + }, + ); }); describe('xmlToIr: boundary event import', () => { @@ -3400,6 +3768,7 @@ describe('xmlToIr: boundary event import', () => { const { ir, warnings } = await xmlToIr(boundaryXml); expect(ir).toEqual({ ...EXPECTED_BOUNDARY_IR, + historyTimeToLive: HISTORY_TIME_TO_LIVE, errorDecls: [{ name: 'OOPS', code: 'OOPS' }], }); expect(warnings).toEqual([]); @@ -3422,22 +3791,15 @@ ${extraFlows} `; describe('refusals', () => { - 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 ' + - 'task, subprocess, attempt block, or call activity that is itself a ' + - 'flow element of this same container; a boundary event can only ' + - 'attach to an activity alongside it'; - it.each([ [ 'a missing attachedToRef', reviewBoundaryDoc(` ${TIMER_1H} `), - 'a boundary event has no attachedToRef; BPMN requires every ' + - 'boundary event to attach to an activity in its own container', 'Orphan', + "a boundary event has no 'attachedToRef'", + 'BPMN requires every boundary event to attach to an activity in its own container', ], [ 'an incoming sequence flow', @@ -3448,19 +3810,18 @@ ${extraFlows} \n', ), - 'a boundary event carries an incoming sequence flow; it is ' + - 'triggered by its own event, not by an incoming flow', 'Boundary_Review_timer', + 'a boundary event carries an incoming sequence flow', + 'it is triggered by its own event, not by an incoming flow', ], [ 'cancelActivity="false" on an error boundary', reviewBoundaryDoc(` `), - 'an error boundary event cannot be non-interrupting ' + - '(cancelActivity="false"); BPMN gives an error boundary no ' + - 'non-interrupting form', 'Boundary_Review_error', + 'an error boundary event cannot be non-interrupting (\'cancelActivity="false"\')', + 'BPMN gives an error boundary no non-interrupting form', ], [ 'an attachedToRef naming a gateway in the same container', @@ -3472,8 +3833,9 @@ ${extraFlows} `, - unattachableDetail('Choose'), 'Boundary_Choose_timer', + `'attachedToRef="Choose"' does not name a plain task, user task, service task, send task, business rule task, receive task, script task, subprocess, attempt block, or call activity that is itself a flow element of this same container`, + 'a boundary event can only attach to an activity alongside it', ], [ // Moddle fills `incoming` from optional children alone; Operaton reads the flow's targetRef. @@ -3484,124 +3846,84 @@ ${extraFlows} \n', ), - 'a boundary event carries an incoming sequence flow; it is ' + - 'triggered by its own event, not by an incoming flow', 'Boundary_Review_timer', + 'a boundary event carries an incoming sequence flow', + 'it is triggered by its own event, not by an incoming flow', ], ] as const)( '%s refuses with UnsupportedEventFeatureError naming the feature', - async (_title, xml, detail, elementId) => { - const e = await expectRefusal( - xmlToIr(xml), - UnsupportedEventFeatureError, - detail, - ); - expect(e.elementId).toBe(elementId); + async (_title, xml, elementId, detail, note) => { + await expectEventRefusal(xml, elementId, detail, [`note: ${note}`]); }, ); - - it('a trigger definition kind the boundary position does not take refuses with UnsupportedEventDefinitionError naming it', async () => { - const e = await expectRefusal( - xmlToIr( - reviewBoundaryDoc(` - - `), - ), - UnsupportedEventDefinitionError, - ); - expect(e.eventKind).toBe('boundary'); - expect(e.definitionType).toBe('bpmn:LinkEventDefinition'); - expect(e.message).toContain( - 'A boundary event supports error, escalation, message, signal, ' + - 'timer, or condition, plus cancel on a block that can be given up.', - ); - }); }); }); describe('xmlToIr: intermediate catch event import', () => { - const unsupportedTriggerXml = (definitionXml: string, doc = bpmnDoc) => - oneNodeDoc('intermediateCatchEvent', { - id: 'Wait', - children: definitionXml, - doc, - }); - - describe('refuses an unsupported trigger', () => { - 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 ' + - 'escalation are caught by an event handler and raised with ' + - 'throw/emit, compensation is undone by a subprocess block, and a ' + - 'cancel is written on the end that gives up an attempt block'; - - it.each([ - [ - 'error', - '', - unawaitableDetail('ErrorEventDefinition'), - ], - ] as const)( - 'a %s trigger refuses with UnsupportedEventFeatureError naming the form', - async (_label, definitionXml, detail) => { - const e = await expectRefusal( - xmlToIr(unsupportedTriggerXml(definitionXml)), - UnsupportedEventFeatureError, - detail, - ); - expect(e.elementId).toBe('Wait'); - }, - ); - }); - - describe('refuses multiple triggers', () => { - const signalRootDoc = (attrs: string, definitions: string) => - rootedDoc( - ' \n', - ` + const signalRootDoc = (attrs: string, definitions: string) => + rootedDoc( + ' \n', + ` ${definitions} `, - ); + ); + const ONE_TRIGGER = + "note: only a single 'message', 'timer', 'signal', 'condition', or 'link' trigger can be awaited"; - it.each([ + it.each([ + [ + 'an error trigger', + oneNodeDoc('intermediateCatchEvent', { + id: 'Wait', + children: '', + doc: bpmnDoc, + }), + "an await cannot carry a 'bpmn:ErrorEventDefinition'", [ - 'two event definitions on one catch', - signalRootDoc( - '', - ` + "note: only 'message', 'timer', 'signal', 'condition', or 'link' triggers can be awaited inline", + "note: an error or an escalation is caught by an event handler and raised with 'throw' or 'emit'", + 'note: compensation is undone by a subprocess block', + 'note: a cancel is written on the end that gives up an attempt block', + ], + ], + [ + 'two event definitions on one catch', + signalRootDoc( + '', + ` PT1H `, - ), - 'an await carries 2 event definitions: only a single message, ' + - 'timer, signal, condition, or link trigger can be awaited', - ], - [ + ), + 'an await carries 2 event definitions', + [ONE_TRIGGER], + ], + [ + 'parallelMultiple="true"', + signalRootDoc( 'parallelMultiple="true"', - signalRootDoc( - 'parallelMultiple="true"', - ' ', - ), - 'an await with parallelMultiple="true" waits for several triggers ' + - 'together; only a single message, timer, signal, condition, or ' + - 'link trigger can be awaited', - ], + ' ', + ), + 'an await with \'parallelMultiple="true"\' waits for several triggers together', + [ONE_TRIGGER], + ], + [ + 'a "none" catch with zero event definitions', + oneNodeDoc('intermediateCatchEvent', { id: 'Wait', doc: bpmnDoc }), + 'an await has no event definition', [ - 'a "none" catch with zero event definitions', - oneNodeDoc('intermediateCatchEvent', { id: 'Wait', doc: bpmnDoc }), - 'an await with no event definition (a "none" intermediate catch) ' + - 'waits for nothing this tool can represent', + 'note: a "none" intermediate catch waits for nothing BPMNscript can represent', ], - ] as const)( - '%s refuses with UnsupportedEventFeatureError naming the form', - async (_title, xml, detail) => { - await expectRefusal(xmlToIr(xml), UnsupportedEventFeatureError, detail); - }, - ); - }); + ], + ] as const)( + '%s refuses with UnsupportedEventFeatureError naming the form', + async (_title, xml, detail, notes) => { + await expectEventRefusal(xml, 'Wait', detail, notes); + }, + ); }); describe('xmlToIr: link events', () => { @@ -3613,7 +3935,6 @@ describe('xmlToIr: link events', () => { throwChildren?: string; throwDef?: string; catchAttrs?: string; - catchDef?: string; extraFlows?: string; defs?: XmlTag; } @@ -3623,7 +3944,6 @@ describe('xmlToIr: link events', () => { throwChildren = '', throwDef = LINK_DEF, catchAttrs = 'name="Retry"', - catchDef = LINK_DEF, extraFlows = '', defs = bpmnDefs, }: PairOptions = {}): string => @@ -3634,7 +3954,7 @@ describe('xmlToIr: link events', () => { ${throwDef} - ${catchDef} + ${LINK_DEF} @@ -3674,74 +3994,69 @@ ${extraFlows} `; elementId: 'ToRetry', category: 'extensionAttribute', message: - `The ${what} on 'ToRetry' was not imported: Operaton creates no ` + - `activity for a link throw, so it never ${does} one.`, + `${what} on 'ToRetry' was not imported\n` + + `note: Operaton creates no activity for a link throw, so it never ${does} one`, }); expect(warnings).toEqual([ - dropped("'asyncBefore' setting", 'reads a setting on'), - 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'), + dropped("setting 'asyncBefore'", 'reads a setting on'), + dropped("setting 'jobPriority'", 'reads a setting on'), + dropped("setting 'retryCycle'", 'reads a setting on'), + dropped("execution listener 'end'", '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: '' }], - ] as const)( - 'a link definition with no name refuses, on %s', - async (_end, id, options) => { - const e = await expectRefusal( - xmlToIr(pairDoc(options)), - UnsupportedEventFeatureError, - 'a link definition carries no name; the name is what a link throw ' + - 'and its catch match on, so one without it has nothing to match', - ); - expect(e.elementId).toBe(id); - expect(e.message).toContain( - 'Give the link definition a name, and the same name to the throw ' + - 'and the catch it joins.', - ); - }, - ); + const FLOW_HELP = + "help: take the flow 'F4' off, and lead it from or to a step instead"; it.each([ [ - 'leaves the link throw', + 'a link definition with no name, on the throw', + pairDoc({ throwDef: '' }), + 'ToRetry', + 'a link definition carries no name', + [ + 'note: the name is what a link throw and its catch match on, so one without it has nothing to match', + 'help: give the link definition a name, and the same name to the throw and the catch it joins', + ], + ], + [ + 'a flow that leaves the link throw', + pairDoc({ + extraFlows: + ' \n', + }), 'ToRetry', - ' \n', - "the flow 'F4' leaves the link throw 'ToRetry'; a link throw ends its " + - 'path, and the token continues at the catch of the same name rather ' + - 'than along a flow', + "the flow 'F4' leaves the link throw 'ToRetry'", + [ + 'note: a link throw ends its path, and the token continues at the catch of the same name rather than along a flow', + FLOW_HELP, + ], ], [ - 'enters the link catch', + 'a flow that enters the link catch', + pairDoc({ + extraFlows: + ' \n', + }), 'AtRetry', - ' \n', - "the flow 'F4' enters the link catch 'AtRetry'; a link catch is " + - 'entered by the throw of the same name rather than along a flow', + "the flow 'F4' enters the link catch 'AtRetry'", + [ + 'note: a link catch is entered by the throw of the same name rather than along a flow', + FLOW_HELP, + ], ], - ] as const)( - 'a flow that %s refuses, naming the flow', - async (_shape, id, extraFlows, detail) => { - const e = await expectRefusal( - xmlToIr(pairDoc({ extraFlows })), - UnsupportedEventFeatureError, - detail, - ); - expect(e.elementId).toBe(id); - expect(e.message).toContain( - "Take the flow 'F4' off, and lead it from or to a step instead.", - ); - }, - ); + ] as const)('%s refuses', async (_title, xml, elementId, detail, notes) => { + await expectEventRefusal(xml, elementId, detail, notes); + }); }); describe('xmlToIr: a label on an event the surface gives no label to', () => { it.each([ [ 'an emit', + 'its own', 'Emit', 'Undo the booking', oneNodeDoc('intermediateThrowEvent', { @@ -3751,9 +4066,21 @@ describe('xmlToIr: a label on an event the surface gives no label to', () => { doc: bpmnDoc, }), ], + [ + 'an emit', + 'its id in words', + 'UndoBooking', + 'Undo Booking', + oneNodeDoc('intermediateThrowEvent', { + id: 'UndoBooking', + attrs: 'name="Undo Booking"', + children: '', + doc: bpmnDoc, + }), + ], ] as const)( - 'on %s the label is dropped, and that is the only thing reported', - async (surface, id, label, xml) => { + 'on %s the label, %s, is dropped, and that is the only thing reported', + async (surface, _which, id, label, xml) => { const { ir, warnings } = await xmlToIr(xml); expect(byId(ir, id)).not.toHaveProperty('name'); expect(warnings).toEqual([ @@ -3761,8 +4088,8 @@ describe('xmlToIr: a label on an event the surface gives no label to', () => { elementId: id, category: 'label', message: - `The label '${label}' on '${id}' was not imported: ${surface} ` + - "has no label in this tool's surface.", + `label '${label}' on '${id}' was not imported\n` + + `note: ${surface} has no label in BPMNscript`, }, ]); }, @@ -3839,22 +4166,27 @@ describe('xmlToIr: BPMN-native assignment and the quantity attributes', () => { 'a humanPerformer beside operaton:assignee', 'operaton:assignee="bob"', role('humanPerformer', 'Lead', 'demo'), - /'Lead'.*"demo".*operaton:assignee="bob".*parseUserTaskCustomExtensions/, + "its 'bpmn:humanPerformer' element 'Lead' names an assignee ('demo') beside 'operaton:assignee=\"bob\"'", + "note: 'BpmnParse.parseUserTaskCustomExtensions' refuses it as a duplicate assignee declaration", ], [ 'two humanPerformers', '', role('humanPerformer', 'Lead', 'demo') + role('humanPerformer', 'Backup', 'mary'), - /2 bpmn:humanPerformer.*parseHumanPerformer/, + "it carries 2 'bpmn:humanPerformer' elements", + "note: Operaton refuses to deploy more than one ('BpmnParse.parseHumanPerformer')", ], - ])('%s is refused', async (_title, attrs, children, detail) => { + ])('%s is refused', async (_title, attrs, children, detail, note) => { const err = await expectRefusal( xmlToIr(oneNodeDoc('userTask', { id: 'Review', attrs, children })), UnsupportedAssignmentError, detail, ); - expect(err.elementId).toBe('Review'); + expect([err.elementId, err.message]).toEqual([ + 'Review', + [`assignment on 'Review' cannot be imported: ${detail}`, note].join('\n'), + ]); }); it.each([ @@ -3865,24 +4197,39 @@ describe('xmlToIr: BPMN-native assignment and the quantity attributes', () => { candidateUsers: '${a}', candidateGroups: "x,${groupOf(b, 'c')}", }, - `imports as candidateUsers: "\${a}" and candidateGroups: "x,\${groupOf(b, 'c')}"`, + `'candidateUsers: "\${a}"' and 'candidateGroups: "x,\${groupOf(b, 'c')}"'`, + "'operaton:candidateUsers' and 'operaton:candidateGroups'", ], [ 'a bare $ opens an expression no } closes, so the comma after it does not split either', 'group(x), a$b, c', { candidateGroups: 'x,a$b, c' }, - 'imports as candidateGroups: "x,a$b, c"', + `'candidateGroups: "x,a$b, c"'`, + "'operaton:candidateGroups'", + ], + [ + 'a trailing comma draws no empty candidate', + 'user(alice), user(bob),', + { candidateUsers: 'alice,bob' }, + `'candidateUsers: "alice,bob"'`, + "'operaton:candidateUsers'", ], ])( 'splits a potentialOwner as parseCommaSeparatedList does: %s', - async (_title, expression, imported, detail) => { + async (_title, expression, imported, settings, attributes) => { const { node, warnings } = await importReview( '', role('potentialOwner', 'Team', expression), ); expect(node).toEqual({ kind: 'userTask', id: 'Review', ...imported }); expect(reported(warnings)).toEqual([ - ['rewritten', 'Review', expect.stringContaining(detail)], + [ + 'rewritten', + 'Review', + `'bpmn:potentialOwner' element 'Team' on 'Review' imports as ${settings}\n` + + "note: Operaton reads its formal expression that way ('BpmnParse.parsePotentialOwnerResourceAssignment')\n" + + `note: BPMNscript writes it back as ${attributes}, which the engine reads the same`, + ], ]); }, ); @@ -3916,44 +4263,64 @@ ${role('potentialOwner', 'Owner_1', 'managers')} [ 'rewritten', 'Review', - expect.stringMatching( - /^The bpmn:humanPerformer 'Lead' on 'Review' imports as assignee: "demo":/, - ), + "'bpmn:humanPerformer' element 'Lead' on 'Review' imports as 'assignee: \"demo\"'\nnote: Operaton reads its formal expression that way ('BpmnParse.parseHumanPerformerResourceAssignment')\nnote: BPMNscript writes it back as 'operaton:assignee', which the engine reads the same", ], [ 'unmappedConstruct', 'Review', - expect.stringMatching( - /^The resourceRef on the bpmn:humanPerformer 'Lead' on 'Review' was not imported:.*formal expression alone/, - ), + "'resourceRef' on the 'bpmn:humanPerformer' element 'Lead' on 'Review' was not imported\nnote: Operaton reads the role's formal expression alone ('BpmnParse.parseHumanPerformerResourceAssignment')", ], [ 'unmappedConstruct', 'Review', - expect.stringMatching( - /^The bpmn:potentialOwner 'Empty' on 'Review' was not imported: it carries no formal expression.*parsePotentialOwnerResourceAssignment/, - ), + "'bpmn:potentialOwner' element 'Empty' on 'Review' was not imported\nnote: it carries no formal expression, and Operaton reads nothing else off it ('BpmnParse.parsePotentialOwnerResourceAssignment')", ], [ 'unmappedConstruct', 'Review', - expect.stringMatching( - /^The bpmn:performer 'Actor' on 'Review' was not imported:.*by tag.*parseTaskDefinition/, - ), + "'bpmn:performer' element 'Actor' on 'Review' was not imported\nnote: Operaton reads a 'bpmn:humanPerformer' and a 'bpmn:potentialOwner' by tag ('BpmnParse.parseTaskDefinition') and no other resource role", ], [ 'unmappedConstruct', 'Review', - expect.stringMatching( - /^The bpmn:potentialOwner 'XsiSpelled' on 'Review' was not imported: it carries no formal expression.*parsePotentialOwnerResourceAssignment/, - ), + "'bpmn:potentialOwner' element 'XsiSpelled' on 'Review' was not imported\nnote: it carries no formal expression, and Operaton reads nothing else off it ('BpmnParse.parsePotentialOwnerResourceAssignment')", ], [ 'unmappedConstruct', 'Svc', - expect.stringMatching( - /^A bpmn:potentialOwner 'Owner_1' on 'Svc' was not imported/, - ), + "'bpmn:potentialOwner' element 'Owner_1' on 'Svc' was not imported\nnote: BPMNscript imports the executable flow and the engine settings on its steps, and nothing declared or drawn beside it", + ], + ]); + }); + + it('a potential owner carrying resourceParameterBindings imports the expression and warns about the rest', async () => { + const { node, warnings } = await importReview( + '', + ` + + user(alice) + + + `, + ); + expect(node).toEqual({ + kind: 'userTask', + id: 'Review', + candidateUsers: 'alice', + }); + expect(reported(warnings)).toEqual([ + [ + 'rewritten', + 'Review', + "'bpmn:potentialOwner' element 'PO_Bindings' on 'Review' imports as 'candidateUsers: \"alice\"'\n" + + "note: Operaton reads its formal expression that way ('BpmnParse.parsePotentialOwnerResourceAssignment')\n" + + "note: BPMNscript writes it back as 'operaton:candidateUsers', which the engine reads the same", + ], + [ + 'unmappedConstruct', + 'Review', + "'resourceParameterBindings' on the 'bpmn:potentialOwner' element 'PO_Bindings' on 'Review' was not imported\n" + + "note: Operaton reads the role's formal expression alone ('BpmnParse.parsePotentialOwnerResourceAssignment')", ], ]); }); @@ -3977,16 +4344,12 @@ ${role('potentialOwner', 'Owner_1', 'managers')} [ 'unmappedConstruct', 'Review', - expect.stringMatching( - /^The 'startQuantity' attribute on 'Review' was not imported: .*BpmnParse never reads it/, - ), + "attribute 'startQuantity' on 'Review' was not imported\nnote: Operaton's 'BpmnParse' never reads it, so the step runs as if it read 1, as the original document does", ], [ 'unmappedConstruct', 'Svc', - expect.stringMatching( - /^The 'completionQuantity' attribute on 'Svc' was not imported: .*BpmnParse never reads it/, - ), + "attribute 'completionQuantity' on 'Svc' was not imported\nnote: Operaton's 'BpmnParse' never reads it, so the step runs as if it read 1, as the original document does", ], ]); }); @@ -4068,10 +4431,9 @@ describe('xmlToIr: flat engine settings on every carrying node kind', () => { 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.', + "'operaton:resultVariableName=\"out\"' on 'T' imports as 'resultVariable: \"out\"'\n" + + "note: 'BpmnParse.parseResultVariable' reads the two spellings as one\n" + + "note: BPMNscript writes it back as 'operaton:resultVariable', which the engine reads the same", }; it.each([ @@ -4099,8 +4461,8 @@ describe('xmlToIr: flat engine settings on every carrying node kind', () => { elementId: 'T', category: 'extensionAttribute', message: - "The 'resultVariableName' setting on 'T' has no effect alongside " + - 'operaton:resultVariable and was not imported.', + "setting 'resultVariableName' on 'T' was not imported\n" + + "note: it has no effect alongside 'operaton:resultVariable'", }, ], }, @@ -4154,12 +4516,12 @@ describe("xmlToIr: a timer job's lock is read where BpmnParse.parseTimer reads i const CONTINUATION_COPY_WARNING = warning( 'Handler', - /'jobPriority' setting on 'Handler' was not imported: the 'on timer' head.*timer job.*continuation job/, + "setting 'jobPriority' on 'Handler' was not imported\nnote: the 'on timer' head's 'jobPriority' configures the timer job its start event creates\nnote: the event sub-process's own copy, which only its async continuation job takes, has no spelling in the script", ); 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/, + "'operaton:exclusive=\"true\"' on the event 'B' was dropped\nnote: it governs the async continuation job of the event\nnote: 'operaton:exclusive=\"false\"' on its timer definition governs the timer job\nnote: BPMNscript keeps one value for both and took the timer definition's", ); it.each< @@ -4208,34 +4570,19 @@ const TIMER_CATCH = ` `; -// 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 = ` +const gatewayDoc = (tag: string, attrs: string, children = ''): string => + operatonDoc` ${children} + ${TIMER_CATCH} - `; - const [head, tail] = placement === 'first' ? [node, others] : [others, node]; - return wrapper` ${head} - ${tail} + `; -}; 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', - }), + gatewayDoc('exclusiveGateway', 'operaton:async="true"'), ); expect(byId(ir, 'G')).toEqual({ kind: 'exclusiveGateway', @@ -4310,32 +4657,91 @@ describe('xmlToIr: input/output parameters', () => { expect(carried).toEqual(['Review', 'Calc', 'Booking', 'Sub']); expect(warnings).toEqual([ unread( - 'operaton:inputOutput', + "'operaton:inputOutput'", 'Throw', - 'a ', - 'reads it on every throw but a link (BpmnParse.parseActivityInputOutput)', + "a 'bpmn:intermediateThrowEvent'", + "reads it on every throw but a link ('BpmnParse.parseActivityInputOutput')", ), unread( - 'operaton:inputOutput', + "'operaton:inputOutput'", 'Wait', - 'a ', - 'reads it (BpmnParse.parseActivityInputOutput)', + "a 'bpmn:intermediateCatchEvent'", + "reads it ('BpmnParse.parseActivityInputOutput')", ), unread( - 'operaton:inputOutput', + "'operaton:inputOutput'", 'End', - 'a ', - 'reads its input parameters (BpmnParse.parseActivityInputOutput)', + "a 'bpmn:endEvent'", + "reads its input parameters ('BpmnParse.parseActivityInputOutput')", ), ]); }); - it('a parameter with an empty body imports as empty text', async () => { - const { node: task, warnings } = await importServiceTask( - ioBlock(' '), + const svcWarning = (message: string): ImportWarning => ({ + elementId: 'Svc', + category: 'extensionAttribute', + message, + }); + const valueAttr = (attr: string): ImportWarning => + svcWarning( + `'${attr}' on an 'operaton:value' in an 'operaton:list' in an 'operaton:inputParameter' named 'x' in an 'operaton:inputOutput' on 'Svc' was not imported\n` + + "note: BPMNscript reads no such setting on an 'operaton:value'\n" + + 'note: the rebuilt document carries none', ); - expect(task.inputParameters).toEqual([ioParam('nothing', textValue(''))]); - expect(warnings).toEqual([]); + + it.each([ + { + title: 'a parameter with an empty body imports as empty text', + children: ioBlock(' '), + params: [ioParam('nothing', textValue(''))], + warnings: [], + }, + { + title: + 'a parameter carrying body text beside one nested child keeps the child and warns on the text', + children: ioBlock( + ` texta`, + ), + params: [ioParam('x', listValue([textValue('a')]))], + warnings: [ + svcWarning( + "body text of 'operaton:inputParameter' 'x' was not imported\n" + + "note: it carries a nested 'operaton:List' value beside it\n" + + "note: 'BpmnParseUtil.parseNestedParamValueProvider' reads the nested value and never the text\n" + + 'note: the rebuilt document carries the nested value alone', + ), + ], + }, + { + title: + 'an operaton:value carrying a modeler id and name reports each, naming the path down to it', + children: ioBlock(` + + z + + `), + params: [ioParam('x', listValue([textValue('z')]))], + warnings: [valueAttr('id'), valueAttr('name')], + }, + { + title: + 'a foreign attribute on the operaton:inputOutput block itself reports', + children: ` + v + `, + params: [ioParam('x', textValue('v'))], + warnings: [ + svcWarning( + "'foo:bar' on an 'operaton:inputOutput' on 'Svc' was not imported\n" + + "note: BPMNscript reads no such setting on an 'operaton:inputOutput'\n" + + 'note: the rebuilt document carries none', + ), + ], + }, + ])('$title', async ({ children, params, warnings: expected }) => { + const { node: task, warnings } = await importServiceTask(children); + expect(task.inputParameters).toEqual(params); + expect(warnings).toEqual(expected); }); const IO = ( @@ -4345,10 +4751,16 @@ describe('xmlToIr: input/output parameters', () => { ${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 ioRefusal = ( + tag: string, + method: string, + suffix = '', + ): [string, string[]] => [ + `an 'operaton:inputOutput' mapping on a 'bpmn:${tag}'`, + [ + `note: 'BpmnParse.${method}' fails the deployment on it ("operaton:inputOutput mapping unsupported for element type '${tag}'${suffix}")`, + ], + ]; const TRIGGERED = " with attribute 'triggeredByEvent = true'"; @@ -4385,7 +4797,7 @@ describe('xmlToIr: input/output parameters', () => { ], [ 'a parallelGateway', - gatewayDoc('parallelGateway', '', { children: IO() }), + gatewayDoc('parallelGateway', '', IO()), 'G', ioRefusal('parallelGateway', 'checkActivityInputOutputSupported'), ], @@ -4406,45 +4818,19 @@ describe('xmlToIr: input/output parameters', () => { ${IO('1')} `, 'E', - 'an operaton:outputParameter on a , which ' + - 'BpmnParse.checkActivityOutputParameterSupported fails the ' + - 'deployment on ("operaton:outputParameter not allowed for element ' + - "type 'endEvent'\")", + [ + "an 'operaton:outputParameter' on a 'bpmn:endEvent'", + [ + "note: 'BpmnParse.checkActivityOutputParameterSupported' fails the deployment on it (\"operaton:outputParameter not allowed for element type 'endEvent'\")", + ], + ], ], - ])( + ] as const)( '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); + async (_title, xml, elementId, [detail, notes]) => { + await expectExtensionRefusal(xml, elementId, detail, notes); }, ); - - it('a parameter carrying body text beside one nested child keeps the child and warns on the text', async () => { - const { node: task, warnings } = await importServiceTask( - ioBlock( - ` texta`, - ), - ); - expect(task.inputParameters).toEqual([ - ioParam('x', listValue([textValue('a')])), - ]); - expect(warnings).toEqual([ - { - elementId: 'Svc', - category: 'extensionAttribute', - message: - "operaton:inputParameter 'x' carries both body text and a " + - 'nested value: BpmnParseUtil' + - '.parseNestedParamValueProvider reads the nested value and ' + - 'never the text, and the document written back carries the ' + - 'nested value alone.', - }, - ]); - }); }); describe('xmlToIr: execution listeners', () => { @@ -4464,23 +4850,23 @@ describe('xmlToIr: execution listeners', () => { ); expect(warnings).toEqual([ unread( - "operaton:executionListener 'start'", + "'operaton:executionListener' named 'start'", 'p', - 'a ', - 'runs it on the process instance (BpmnParse.parseExecutionListenersOnScope)', + "a 'bpmn:process'", + "runs it on the process instance ('BpmnParse.parseExecutionListenersOnScope')", ), unread( - "operaton:executionListener 'end'", + "'operaton:executionListener' named 'end'", 'G', - 'a ', - 'runs it (BpmnParse.parseExecutionListenersOnScope)', + "a 'bpmn:exclusiveGateway'", + "runs it ('BpmnParse.parseExecutionListenersOnScope')", ), unread( - "operaton:executionListener 'take'", + "'operaton:executionListener' named 'take'", 'F2', - 'a ', + "a 'bpmn:sequenceFlow'", 'runs it when the flow is taken, whatever its event says ' + - '(BpmnParse.parseExecutionListenersOnTransition)', + "('BpmnParse.parseExecutionListenersOnTransition')", ), ]); }); @@ -4495,9 +4881,9 @@ describe('xmlToIr: execution listeners', () => { 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.', + "an 'operaton:executionListener' on 'Svc' has 'expression=\"\"'\n" + + "note: 'ExpressionExecutionListener' evaluates the empty text rather than refusing it\n" + + 'note: the rebuilt document carries it', }, ], }, @@ -4510,8 +4896,8 @@ describe('xmlToIr: execution listeners', () => { elementId: 'Svc', category: 'extensionAttribute', message: - "The 'expression' setting on an operaton:executionListener on " + - "'Svc' has no effect alongside class and was not imported.", + "setting 'expression' on an 'operaton:executionListener' on 'Svc' was not imported\n" + + "note: it has no effect alongside 'class'", }, ], }, @@ -4523,46 +4909,39 @@ describe('xmlToIr: execution listeners', () => { }); describe('xmlToIr: task listeners', () => { - it('the five non-timeout events import in emission order', async () => { - const { node: task, warnings } = await importUserTaskWith( - ` + it.each([ + { + case: 'the five non-timeout events import in emission order, each with its binding', + children: ` `, - ); - expect(task.taskListeners?.map((l) => l.event)).toEqual([ - 'create', - 'assignment', - 'complete', - 'update', - 'delete', - ]); - expect(task.taskListeners?.[1].binding).toEqual( - exprBinding('${bean.assign()}'), - ); - expect(warnings).toEqual([]); - }); - - it('a timeout listener carries its timer as a timer event definition', async () => { - const { node: task, warnings } = await importUserTaskWith( - ` + listeners: [ + { event: 'create', binding: classBinding('com.example.C') }, + { event: 'assignment', binding: exprBinding('${bean.assign()}') }, + { event: 'complete', binding: delegateBinding('${bean}') }, + { event: 'update', binding: classBinding('com.example.U') }, + { event: 'delete', binding: classBinding('com.example.D') }, + ], + warnings: [], + }, + { + case: 'a timeout listener carries its timer as a timer event definition', + children: ` PT8H `, - ); - expect(task.taskListeners).toEqual([ - { - event: 'timeout', - binding: classBinding('com.example.T'), - timer: timerDef('duration', 'PT8H'), - }, - ]); - expect(warnings).toEqual([]); - }); - - it.each([ + listeners: [ + { + event: 'timeout', + binding: classBinding('com.example.T'), + timer: timerDef('duration', 'PT8H'), + }, + ], + warnings: [], + }, { case: 'an empty class carries as written, since parseTaskListener checks no attribute for emptiness', children: ` `, @@ -4572,11 +4951,9 @@ describe('xmlToIr: task listeners', () => { 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.', + "an 'operaton:taskListener' on 'Review' has 'class=\"\"' and fails when its event fires\n" + + "note: 'BpmnParse.parseTaskListener' checks no listener attribute for emptiness, so the task deploys\n" + + 'note: the rebuilt document carries the empty text', }, ], }, @@ -4589,9 +4966,9 @@ describe('xmlToIr: task listeners', () => { 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.', + "an 'operaton:taskListener' on 'Review' has 'expression=\"\"'\n" + + "note: 'ExpressionTaskListener' evaluates the empty text rather than refusing it\n" + + 'note: the rebuilt document carries it', }, ], }, @@ -4604,11 +4981,9 @@ describe('xmlToIr: task listeners', () => { 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.', + "an 'operaton:taskListener' on 'Review' has 'delegateExpression=\"\"' and fails when its event fires\n" + + "note: 'BpmnParse.parseTaskListener' checks no listener attribute for emptiness, so the task deploys\n" + + 'note: the rebuilt document carries the empty text', }, ], }, @@ -4631,12 +5006,9 @@ describe('xmlToIr: task listeners', () => { 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.', + "'operaton:exclusive=\"false\"' on the timer of an 'operaton:taskListener' with 'event=\"timeout\"' on 'Review' was not imported\n" + + 'note: BPMNscript has no setting for it there\n' + + "note: Operaton locks the timeout job by it ('BpmnParse.parseTimeoutTaskListener' through 'parseTimer'), so the rebuilt document runs without it", }, ], }, @@ -4653,10 +5025,9 @@ describe('xmlToIr: task listeners', () => { 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.', + "'bpmn:TimerEventDefinition' on an 'operaton:taskListener' with 'event=\"create\"' was not imported\n" + + "note: 'BpmnParse.parseTaskListener' reads no event definition off a listener that is not a timeout\n" + + 'note: the rebuilt document carries none', }, ], }, @@ -4667,43 +5038,6 @@ describe('xmlToIr: task listeners', () => { }); }); -describe('xmlToIr: a consumed extension child reports its own unread attributes', () => { - it('an operaton:value carrying a modeler id or name reports that drop', async () => { - const { node: task, warnings } = await importServiceTask( - ioBlock(` - - z - - `), - ); - expect(task.inputParameters).toEqual([ - ioParam('x', listValue([textValue('z')])), - ]); - expect(warnings).toHaveLength(2); - expect(warnings.map((w) => w.elementId)).toEqual(['Svc', 'Svc']); - expect(warnings[0].message).toMatch(/'id' on an operaton:value/); - expect(warnings[1].message).toMatch(/'name' on an operaton:value/); - expect(warnings[0].message).toMatch(/operaton:inputParameter 'x'/); - }); - - it.each([ - [ - 'a foreign attribute on the operaton:inputOutput block itself reports', - serviceTaskWith( - ` - v - `, - ), - [/'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( - expected.map((pattern) => expect.stringMatching(pattern)), - ); - }); -}); - const field = (attrs: string, body = ''): string => body === '' ? ` ` @@ -4711,7 +5045,7 @@ const field = (attrs: string, body = ''): string => 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}`; + `shell binding and into no other\nnote: this one is bound by ${binding}`; describe('xmlToIr: an injected field rides a class or a delegate binding', () => { const importBound = (tag: string, attrs: string, fields: string) => @@ -4737,7 +5071,7 @@ describe('xmlToIr: an injected field rides a class or a delegate binding', () => }); const dropped = (reason: string, name = "'greeting'"): ImportWarning => - warned(`The injected field ${name} on 'Svc' was not imported: ${reason}.`); + warned(`injected field ${name} on 'Svc' was not imported\nnote: ${reason}`); const CLASS = 'operaton:class="com.example.Svc"'; const GREETING = field('name="greeting" stringValue="hello"'); @@ -4765,9 +5099,8 @@ describe('xmlToIr: an injected field rides a class or a delegate binding', () => SVC_INJECTED, [ 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.', + "injected field 'greeting' on 'Svc' is rewritten from an 'operaton:string' child to a 'stringValue' attribute\n" + + 'note: the engine injects the same text either way', ), ], ], @@ -4779,8 +5112,8 @@ describe('xmlToIr: an injected field rides a class or a delegate binding', () => SVC, [ dropped( - "a stringValue attribute holding '${who}' would be written back " + - 'as an operaton:expression child, and the engine would evaluate ' + + "a 'stringValue' attribute holding '${who}' would be written back " + + "as an 'operaton:expression' child, and the engine would evaluate " + 'it rather than inject the text', ), ], @@ -4796,8 +5129,8 @@ describe('xmlToIr: an injected field rides a class or a delegate binding', () => SVC, [ dropped( - 'an operaton:expression child holding ' + - "'hello\\n world' would be written back as a stringValue " + + "an 'operaton:expression' child holding " + + "'hello\\n world' would be written back as a 'stringValue' " + 'attribute, and the engine would inject that text rather than ' + 'evaluate it', ), @@ -4814,7 +5147,7 @@ describe('xmlToIr: an injected field rides a class or a delegate binding', () => SVC, [ dropped( - 'an operaton:expression child holding ' + + "an 'operaton:expression' child holding " + "'\\n ${who}\\n ' would be written back as a " + 'quoted literal the compiler refuses, since a raw template opens ' + 'directly after its quote', @@ -4832,9 +5165,8 @@ describe('xmlToIr: an injected field rides a class or a delegate binding', () => SVC_INJECTED, [ warned( - "The 'operaton:expression' child of the injected field 'greeting' " + - "on 'Svc' has no effect alongside a stringValue attribute and was " + - 'not imported.', + "'operaton:expression' child of the injected field 'greeting' on 'Svc' was not imported\n" + + "note: it has no effect alongside a 'stringValue' attribute", ), ], ], @@ -4862,7 +5194,7 @@ describe('xmlToIr: an injected field rides a class or a delegate binding', () => decisionRef: 'riskRating', binding: { kind: 'latest' }, }, - [dropped(boundElsewhere('an operaton:decisionRef'))], + [dropped(boundElsewhere("an 'operaton:decisionRef'"))], ], ]; @@ -4877,11 +5209,14 @@ describe('xmlToIr: an injected field rides a class or a delegate binding', () => it('a field on a step with no binding to inject into is reported whole', async () => { const { warnings } = await importUserTaskWith(GREETING); - expect(warnings.map((w) => w.message)).toEqual([ - "The injected field 'greeting' on 'Review' was not imported: 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.', + expect(warnings).toEqual([ + { + elementId: 'Review', + category: 'extensionAttribute', + message: + "injected field 'greeting' on 'Review' was not imported\n" + + 'note: BPMNscript 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', + }, ]); }); }); @@ -4912,7 +5247,7 @@ describe('xmlToIr: a mail or shell task imports with its fields on the three tag const shadowed = (attr: string, winner: string): ImportWarning => ({ elementId: 'T', category: 'extensionAttribute', - message: `The '${attr}' setting on 'T' has no effect alongside ${winner} and was not imported.`, + message: `setting '${attr}' on 'T' was not imported\nnote: it has no effect alongside '${winner}'`, }); it.each([ @@ -4928,10 +5263,9 @@ describe('xmlToIr: a mail or shell task imports with its fields on the three tag 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.', + "result variable 'out' on 'T' is never written\n" + + 'note: it was imported as written, and the printed script draws a warning at the step\n' + + "note: 'BpmnParse.parseServiceTaskLike' hands it to an expression binding alone, so an 'operaton:type' binding never writes it", }, ], }, @@ -4952,10 +5286,9 @@ describe('xmlToIr: a mail or shell task imports with its fields on the three tag elementId: 'T', 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: ' + - 'ShellActivityBehavior.readFields compares it with "true" ' + - 'case-sensitively, so the engine reads it as false.', + "shell field 'wait' on 'T' is 'TRUE', which the engine reads as false\n" + + 'note: it was imported as written, and the printed script draws an error at the field\n' + + "note: 'ShellActivityBehavior.readFields' compares it with 'true' case-sensitively", }, ], }, @@ -4986,6 +5319,10 @@ describe('xmlToIr: a service-like task the engine would refuse is refused with i const TO = field('name="to" stringValue="ops@example.com"'); const TEXT = field('name="text" stringValue="Report attached"'); const COMMAND = field('name="command" stringValue="echo"'); + const SUPPORTED_FORMS = [ + 'note: the 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 a decision reference on a business rule task', + "note: their attributes are 'operaton:class' (or the deprecated 'camunda:class' alias), 'operaton:expression', 'operaton:delegateExpression', 'operaton:type=\"external\"' with 'operaton:topic', 'operaton:type=\"mail\"' or 'operaton:type=\"shell\"' with their 'operaton:field' children, and 'operaton:decisionRef'", + ]; const typed = (type: string, fields: string[], attrs = ''): string => oneNodeDoc('serviceTask', { @@ -4995,40 +5332,116 @@ describe('xmlToIr: a service-like task the engine would refuse is refused with i it.each([ [ - 'a shell task with a field written as an expression', - typed('shell', [ - COMMAND, - field('name="arg1"', '${x}'), - ]), + 'an external type without a topic', + oneNodeDoc('serviceTask', { attrs: 'operaton:type="external"' }), 'Service task', - 'operaton:type="shell" with the field \'arg1\' written as an ' + - 'operaton:expression, which Operaton fails to deploy: ' + - 'BpmnParse.validateFieldDeclarationsForShell casts every shell field ' + - 'to a FixedValue, and an expression is not one', + 'T', + "'operaton:type=\"external\"' without an 'operaton:topic'", + SUPPORTED_FORMS, ], [ - 'a shell wait flag outside true and false', - typed('shell', [COMMAND, field('name="wait" stringValue="yes"')]), + 'a connector, which the Connect plugin runs in place of the binding beside it', + oneNodeDoc('serviceTask', { + children: extensionElements( + 'http-connector', + ), + }), + 'Service task', + 'T', + "an 'operaton:connector' element", + [ + "note: the Connect plugin runs it in place of whatever 'operaton:class', 'expression', 'delegateExpression' or 'type' names beside it", + 'note: an engine without the plugin runs that binding instead of it, so the same file has two possible executions', + ...SUPPORTED_FORMS, + ], + ], + [ + 'a send task with no binding, named as a send task', + oneNodeDoc('sendTask'), + 'Send task', + 'T', + 'no execution discriminator', + SUPPORTED_FORMS, + ], + [ + 'a shell task with a field written as an expression', + typed('shell', [ + COMMAND, + field('name="arg1"', '${x}'), + ]), 'Service task', - "operaton:type=\"shell\" with the field 'wait' set to 'yes', which " + - 'Operaton refuses to deploy: "undefined value for shell wait ' + - 'parameter :yes" (BpmnParse.validateFieldDeclarationsForShell)', + 'T', + "'operaton:type=\"shell\"' with the field 'arg1' written as an 'operaton:expression'", + [ + "note: Operaton fails to deploy it, since 'BpmnParse.validateFieldDeclarationsForShell' casts every shell field to a 'FixedValue', and an expression is not one", + ], + ], + [ + 'a shell wait flag outside true and false', + typed('shell', [COMMAND, field('name="wait" stringValue="yes"')]), + 'Service task', + 'T', + "'operaton:type=\"shell\"' with the field 'wait' set to 'yes'", + [ + 'note: Operaton refuses to deploy it with "undefined value for shell wait parameter :yes" (\'BpmnParse.validateFieldDeclarationsForShell\')', + ], + ], + [ + 'a mail task with no fields at all, the same as one missing "to"', + typed('mail', []), + 'Service task', + 'T', + "'operaton:type=\"mail\"' without a 'to' field", + [ + 'note: Operaton refuses to deploy it with "No recipient is defined on the mail activity" (\'BpmnParse.validateFieldDeclarationsForEmail\')', + ], ], [ 'a mail field the behaviour does not declare', typed('mail', [TO, TEXT, field('name="recipient" stringValue="x"')]), 'Service task', - 'operaton:type="mail" with a field \'recipient\', which the mail ' + - 'behaviour does not declare; Operaton refuses to deploy it: "Field ' + - "definition uses unexisting field 'recipient'\" " + - '(ClassDelegateUtil.applyFieldDeclaration)', + 'T', + "'operaton:type=\"mail\"' with a field 'recipient', which the mail behaviour does not declare", + [ + "note: Operaton refuses to deploy it with \"Field definition uses unexisting field 'recipient'\" ('ClassDelegateUtil.applyFieldDeclaration')", + ], ], [ 'an operaton:type outside mail, shell and external, beside the class it outranks', typed('ftp', [], 'operaton:class="com.example.Svc"'), 'Service task', - 'operaton:type="ftp", which Operaton resolves ahead of the ' + - 'operaton:class alongside it', + 'T', + '\'operaton:type="ftp"\'', + [ + "note: Operaton resolves it ahead of the 'operaton:class' alongside it", + ...SUPPORTED_FORMS, + ], + ], + [ + 'a business rule task with a mapDecisionResult outside the four Operaton accepts', + oneNodeDoc('businessRuleTask', { + attrs: + 'operaton:decisionRef="d" operaton:mapDecisionResult="firstEntry"', + }), + 'Business rule task', + 'T', + '\'operaton:mapDecisionResult="firstEntry"\'', + [ + 'note: it is not a way of filling the result variable BPMNscript can represent', + ], + ], + [ + 'a business rule task with a tenant, as the call activity does', + oneNodeDoc('businessRuleTask', { + attrs: 'operaton:decisionRef="d" operaton:decisionRefTenantId="acme"', + }), + 'Business rule task', + 'T', + 'it names \'operaton:decisionRefTenantId="acme"\'', + [ + "note: it pins the tenant 'BpmnParse.parseTenantId' resolves the decision against", + 'note: dropping it would change which decision runs, and BPMNscript has no tenant setting', + ], ], [ 'a built-in type on the definition of a thrown message', @@ -5041,43 +5454,51 @@ describe('xmlToIr: a service-like task the engine would refuse is refused with i `, 'Thrown message', - 'operaton:type="mail", which this surface carries on a service, send ' + - 'or business rule task alone', + 'Typed', + '\'operaton:type="mail"\'', + [ + 'note: BPMNscript carries it on a service, send or business rule task alone', + ...THROWN_MESSAGE_FORMS, + ], ], - ])('%s', async (_title, xml, subject, construct) => { - const e = await expectRefusal( - xmlToIr(xml), - UnsupportedServiceTaskFormError, - ); - expect(e.subject).toBe(subject); - expect(e.construct).toBe(construct); - }); + ] as const)( + '%s refuses', + async (_title, xml, subject, id, construct, notes) => { + const e = await expectRefusal( + xmlToIr(xml), + UnsupportedServiceTaskFormError, + ); + expect([e.subject, e.serviceTaskId, e.construct, e.message]).toEqual([ + subject, + id, + construct, + [ + `${subject.toLowerCase()} '${id}' uses an unsupported execution form: ${construct}`, + ...notes, + ].join('\n'), + ]); + }, + ); }); describe('xmlToIr: a user task names a deployed form by reference', () => { const importFormRef = (attrs: string) => importOnly(oneNodeDoc('userTask', { attrs }), 'userTask'); - it('a formRef resolving to the form deployed alongside the process imports the key and the binding together, reporting nothing', async () => { - const { node, warnings } = await importFormRef( - 'operaton:formRef="review-form" operaton:formRefBinding="deployment"', - ); - expect(node).toEqual({ - kind: 'userTask', - id: 'T', - formRef: { key: 'review-form', binding: { kind: 'deployment' } }, - }); - expect(warnings).toEqual([]); - }); - it.each([ + [ + 'a formRef resolving to the form deployed alongside the process imports the key and the binding together, reporting nothing', + 'operaton:formRef="review-form" operaton:formRefBinding="deployment"', + { key: 'review-form', binding: { kind: 'deployment' } }, + [], + ], [ 'a version the binding beside it never resolves is reported, and the reference still imports', 'operaton:formRef="review-form" operaton:formRefBinding="latest" operaton:formRefVersion="3"', { key: 'review-form', binding: { kind: 'latest' } }, [ - "The 'formRefVersion' setting on 'T' has no effect without " + - 'formRefBinding="version" and was not imported.', + "setting 'formRefVersion' on 'T' was not imported\n" + + 'note: it has no effect without \'formRefBinding="version"\'', ], ], [ @@ -5085,15 +5506,19 @@ describe('xmlToIr: a user task names a deployed form by reference', () => { 'operaton:formRefBinding="latest" operaton:formRefVersion="3"', undefined, [ - "The 'formRefBinding' setting on 'T' has no effect without an " + - 'operaton:formRef and was not imported.', - "The 'formRefVersion' setting on 'T' has no effect without an " + - 'operaton:formRef and was not imported.', + "setting 'formRefBinding' on 'T' was not imported\n" + + "note: it has no effect without an 'operaton:formRef'", + "setting 'formRefVersion' on 'T' was not imported\n" + + "note: it has no effect without an 'operaton:formRef'", ], ], ] as const)('%s', async (_title, attrs, formRef, messages) => { const { node, warnings } = await importFormRef(attrs); - expect(node.formRef).toEqual(formRef); + expect(node).toEqual({ + kind: 'userTask', + id: 'T', + ...(formRef === undefined ? {} : { formRef }), + }); expect(warnings.map((w) => w.message)).toEqual(messages); }); @@ -5101,55 +5526,46 @@ describe('xmlToIr: a user task names a deployed form by reference', () => { [ 'a formKey beside a formRef', 'operaton:formKey="embedded:app:forms/review.html" operaton:formRef="review-form" operaton:formRefBinding="latest"', - 'it names an operaton:formKey beside the operaton:formRef, and a task renders one form', + "it names an 'operaton:formKey' beside the 'operaton:formRef'", + [ + 'note: a task renders one form', + 'note: Operaton refuses to deploy a user task that names both a form key and a form reference', + ], ], [ 'a formRef with no binding to resolve it', 'operaton:formRef="review-form"', - 'its operaton:formRef carries no operaton:formRefBinding, so the engine cannot resolve which deployed form to render', + "its 'operaton:formRef' carries no 'operaton:formRefBinding'", + [ + 'note: the engine cannot resolve which deployed form to render', + 'note: Operaton refuses to deploy a user task whose form reference has no binding', + ], ], [ 'a formRef bound by a word this tool cannot represent', 'operaton:formRef="review-form" operaton:formRefBinding="versionTag"', - 'formRefBinding="versionTag" is outside the bindings ' + - 'BpmnParse.parseFormDefinition resolves (deployment, latest, ' + - 'version), so the engine refuses to deploy it', + "'formRefBinding=\"versionTag\"' is outside the bindings 'BpmnParse.parseFormDefinition' resolves", + [ + "note: it resolves 'deployment', 'latest' and 'version', and the engine refuses to deploy any other", + ], ], ])( '%s refuses, rather than importing a task Operaton would not deploy', - async (_title, attrs, detail) => { + async (_title, attrs, detail, notes) => { const error = await expectRefusal( xmlToIr(oneNodeDoc('userTask', { attrs })), UnsupportedFormReferenceError, detail, ); - expect(error.message).toContain("The form reference on 'T'"); + expect(error.message).toBe( + [`form reference on 'T' cannot be imported: ${detail}`, ...notes].join( + '\n', + ), + ); }, ); }); -describe('xmlToIr: a second extension block of one kind refuses, since Element.elementNS throws on it', () => { - const twice = (block: string): string => `${block}\n${block}`; - - it('a second operaton:formData refuses, naming the reader that meets both', async () => { - const e = await expectRefusal( - xmlToIr( - userTaskWith( - twice(` - - `), - ), - ), - UnsupportedExtensionFormError, - '2 blocks, which Element.elementNS throws on ' + - 'when DefaultFormHandler.parseFormData reads them ("Parsing ' + - "exception: multiple elements with tag name 'formData' found\"), " + - 'and BpmnParse.execute lets that fail the deployment', - ); - expect(e.elementId).toBe('Review'); - }); -}); - describe('xmlToIr: an initiator and a form on a start that is not the process own', () => { const START_FORM = ` @@ -5162,20 +5578,20 @@ describe('xmlToIr: an initiator and a form on a start that is not the process ow 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.', + `setting 'operaton:initiator' on '${id}' was not imported\n` + + "note: 'BpmnParse.parseScopeStartEvent' reads no 'operaton:' attribute " + + "off a start that is not the process's own\n" + + "note: 'parseProcessDefinitionStartEvent' reads it on the process's own start alone\n" + + 'note: the rebuilt document 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.', + `'operaton:formData' block on '${id}' was not imported\n` + + "note: 'BpmnParse.parseStartFormHandlers' runs for the process's own " + + "start alone, and 'parseScopeStartEvent' reads no form\n" + + 'note: the rebuilt document runs the same', }, ]; @@ -5270,42 +5686,56 @@ ${validation(` expect(warnings).toEqual([]); }); + const CONSTRAINT_NAMES = [ + "note: Operaton registers 'required', 'readonly', 'min', 'max', 'minlength', and 'maxlength', and reads a custom validator class or expression off 'validator'", + "note: 'FormValidators.createValidator' fails the deployment on any other name", + ]; + it.each([ { case: 'a name Operaton registers no validator for', constraints: ' ', name: 'minimum', - detail: /no validator is registered/, + detail: 'no validator is registered under that name', + notes: CONSTRAINT_NAMES, }, { case: 'a constraint with no name', constraints: ' ', name: '(none)', - detail: /no name/, + detail: 'it has no name', + notes: CONSTRAINT_NAMES, }, { case: "a 'validator' with no config", constraints: ' ', name: 'validator', - detail: /FormValidators\.createValidator/, + detail: 'it has no config', + notes: [ + "note: 'FormValidators.createValidator' needs a class name or an expression in its config", + ], }, { case: "a 'min' with no config", constraints: ' ', name: 'min', - detail: /every submission/, + detail: 'it has no config', + notes: [ + "note: 'MinValidator.validate' parses its config on every submission", + ], }, { case: "'required' written twice", constraints: ` `, name: 'required', - detail: - 'DefaultFormHandler.parseValidation deploys both, and this script ' + - 'holds each constraint once per field', + detail: 'the field carries it twice', + notes: [ + "note: 'DefaultFormHandler.parseValidation' deploys both, and BPMNscript holds each constraint once per field", + ], }, - ])('refuses $case', async ({ constraints, name, detail }) => { + ])('refuses $case', async ({ constraints, name, detail, notes }) => { const err = await expectRefusal( xmlToIr( userTaskWith( @@ -5317,21 +5747,26 @@ ${validation(constraints)} UnsupportedFormFieldConstraintError, detail, ); - expect([err.elementId, err.fieldId, err.constraintName]).toEqual([ + expect([ + err.elementId, + err.fieldId, + err.constraintName, + err.message, + ]).toEqual([ 'Review', 'amount', name, + [ + `constraint '${name}' on form field 'amount' of 'Review' cannot be imported: ${detail}`, + ...notes, + ].join('\n'), ]); - expect(err.message).toMatch( - /required, readonly, min, max, minlength, and maxlength/, - ); - expect(err.message).toMatch(/'validator'/); }); - const reviewWarning = (message: RegExp) => warning('Review', message); - const carried = (message: RegExp) => + const reviewWarning = (message: string) => warning('Review', message); + const carried = (message: string) => warning('Review', message, 'carriedAsWritten'); - const rewrote = (message: RegExp) => warning('Review', message, 'rewritten'); + const rewrote = (message: string) => warning('Review', message, 'rewritten'); it.each([ { @@ -5340,7 +5775,7 @@ ${validation(constraints)} imported: { id: 'note', type: 'string' }, warnings: [ reviewWarning( - /'datePattern'.*'note'.*FormTypes\.parseFormPropertyType/, + "'datePattern' on form field 'note' of 'Review' was not imported\nnote: 'FormTypes.parseFormPropertyType' reads it on a date field alone", ), ], }, @@ -5353,7 +5788,19 @@ ${validation(constraints)} imported: { id: 'note', type: 'string' }, warnings: [ reviewWarning( - /2 operaton:value.*'note'.*FormTypes\.parseFormPropertyType/, + "2 'operaton:value' children of form field 'note' of 'Review' were not imported\nnote: 'FormTypes.parseFormPropertyType' reads them on an enum field alone", + ), + ], + }, + { + case: 'a single operaton:value on a string field is dropped, reported in the singular', + field: ` + + `, + imported: { id: 'note', type: 'string' }, + warnings: [ + reviewWarning( + "1 'operaton:value' child of form field 'note' of 'Review' was not imported\nnote: 'FormTypes.parseFormPropertyType' reads them on an enum field alone", ), ], }, @@ -5368,7 +5815,9 @@ ${validation(' ')} }, warnings: [ carried( - /'min'.*imported as written.*number field.*MinValidator\.validate/, + "the engine cannot use 'min' constraint on form field 'note' of 'Review' as written\nnote: 'min' fits a number field alone, and 'note' is a string field, whose every submitted value 'MinValidator.validate' refuses\nnote: it was imported as written, and the printed script draws an error at the field", ), ], }, @@ -5398,7 +5847,9 @@ ${validation(' ')} constraints: [{ name: 'min', config: 'abc' }], }, warnings: [ - carried(/'min'.*imported as written.*MinValidator\.validate.*'abc'/), + carried( + "the engine cannot use 'min' constraint on form field 'amount' of 'Review' as written\nnote: 'MinValidator.validate' parses its config as an integer on every submission, and 'abc' is not one\nnote: it was imported as written, and the printed script draws an error at the field", + ), ], }, { @@ -5414,7 +5865,9 @@ ${validation(' ')} values: [{ id: 'a' }, { id: 'b' }], }, warnings: [ - carried(/'zzz'.*imported as written.*EnumFormType\.validateValue/), + carried( + "the engine cannot use default 'zzz' on form field 'plan' of 'Review' as written\nnote: it names none of the field's values, and 'EnumFormType.validateValue' refuses it on every render of the form\nnote: it was imported as written, and the printed script draws an error at the field", + ), ], }, { @@ -5423,7 +5876,7 @@ ${validation(' ')} 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/, + "the engine cannot use default '1.5' on form field 'amount' of 'Review' as written\nnote: 'LongFormType.convertValue' parses it with 'Long.valueOf' on every render of the form, and it is not an integer\nnote: it was imported as written, and the printed script draws an error at the field", ), ], }, @@ -5433,7 +5886,7 @@ ${validation(' ')} imported: { id: 'flag', type: 'boolean', defaultValue: 'maybe' }, warnings: [ carried( - /^The default 'maybe' on form field 'flag' of 'Review' was imported as written.*BooleanFormType\.convertValue.*Boolean\.valueOf/, + "the engine cannot use default 'maybe' on form field 'flag' of 'Review' as written\nnote: 'BooleanFormType.convertValue' reads it through 'Boolean.valueOf' on every render of the form\nnote: it was imported as written, and the printed script draws an error at the field", ), ], }, @@ -5443,7 +5896,7 @@ ${validation(' ')} imported: { id: 'due', type: 'date', defaultValue: '2026-01-01' }, warnings: [ carried( - /^The default '2026-01-01' on form field 'due' of 'Review' was imported as written.*DateFormType.*dd\/MM\/yyyy/, + "the engine cannot use default '2026-01-01' on form field 'due' of 'Review' as written\nnote: 'DateFormType' parses it on every render of the form under the engine's own 'dd/MM/yyyy' ('ProcessEngineConfigurationImpl.initFormTypes'), since the field names no pattern, and an ISO date does not fit it\nnote: it was imported as written, and the printed script draws an error at the field", ), ], }, @@ -5465,7 +5918,11 @@ ${validation(' ')} type: 'enum', values: [{ id: 'a', label: 'Again' }, { id: 'b' }], }, - warnings: [rewrote(/value 'a'.*'plan'.*LinkedHashMap/)], + warnings: [ + rewrote( + "'operaton:value' 'a' of form field 'plan' of 'Review' is written twice and was imported once\nnote: it keeps its first position and its last name, as 'FormTypes.parseFormPropertyType' keeps it ('LinkedHashMap.put')", + ), + ], }, { case: 'an enum value with no id is dropped', @@ -5474,7 +5931,11 @@ ${validation(' ')} `, imported: { id: 'plan', type: 'enum', values: [{ id: 'a' }] }, - warnings: [reviewWarning(/operaton:value #2.*'plan'.*no id/)], + warnings: [ + reviewWarning( + "'operaton:value' #2 of form field 'plan' of 'Review' has no id and was not imported", + ), + ], }, { case: 'a repeated property id keeps the first position and the last value', @@ -5495,7 +5956,7 @@ ${validation(' ')} }, warnings: [ rewrote( - /property 'hint'.*'note'.*once, at its first position with its last value, as DefaultFormHandler.parseProperties keeps it \(LinkedHashMap.put\)/, + "'operaton:property' 'hint' of form field 'note' of 'Review' is written twice and was imported once\nnote: it keeps its first position and its last value, as 'DefaultFormHandler.parseProperties' keeps it ('LinkedHashMap.put')", ), ], }, @@ -5518,8 +5979,12 @@ ${validation(' ')} ], }, warnings: [ - reviewWarning(/operaton:property #1.*'note'.*no id/), - reviewWarning(/operaton:property 'empty'.*'note'.*no value/), + reviewWarning( + "'operaton:property' #1 of form field 'note' of 'Review' has no 'id' and was not imported", + ), + reviewWarning( + "'operaton:property' 'empty' of form field 'note' of 'Review' has no 'value' and was not imported", + ), ], }, ] satisfies { @@ -5560,12 +6025,10 @@ const propertiesOf = (entries: string): string => ` \n${entries}\n `; describe('xmlToIr: external task extras', () => { - const CODED_ROOT = - ' \n'; - const BLANK_ROOT = ' \n'; - - const mappingDoc = (mapping: string, roots = CODED_ROOT): string => - operatonDefs`${roots} + const mappingDoc = (mapping: string): string => + operatonDefs` + + ${externalTask('', mapping)} @@ -5575,53 +6038,43 @@ ${externalTask('', mapping)} [ 'a mapping whose errorRef names no root and carries no expression, which the engine checks before the root', errorMapping('errorRef="Err_Missing"'), - /no expression/, + "the 'operaton:errorEventDefinition' carries no expression", + "note: Operaton's 'parseOperatonErrorEventDefinitions' fails the deployment on an 'operaton:errorEventDefinition' that carries no expression", ], [ 'a mapping whose errorRef names a root with no code', errorMapping('errorRef="Err_Blank" expression="${true}"'), - /'Err_Blank'.*no code/, + "its 'errorRef' names the error root 'Err_Blank', which carries no code", + "note: BPMNscript needs its 'errorRef' to name an error root that carries a code", ], - ])('%s is refused', async (_title, mapping, detail) => { + ])('%s is refused', async (_title, mapping, detail, note) => { const err = await expectRefusal( - xmlToIr(mappingDoc(mapping, CODED_ROOT + BLANK_ROOT)), + xmlToIr(mappingDoc(mapping)), UnsupportedErrorMappingError, detail, ); - expect(err.elementId).toBe('Charge'); - expect(err.message).toContain('parseOperatonErrorEventDefinitions'); + expect([err.elementId, err.message]).toEqual([ + 'Charge', + `error mapping on 'Charge' cannot be imported: ${detail}\n${note}`, + ]); }); 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.', + "'operaton:errorEventDefinition' on 'Charge' was not imported\n" + + "note: it carries no 'errorRef', and 'BpmnParse.parseOperatonErrorEventDefinitions' skips one without it\n" + + 'note: the rebuilt document 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 mapping with no errorRef is skipped with a warning, as the engine skips it', + body: externalTask('', errorMapping('expression="${true}"')), + binding: externalBinding('charge-card'), + warnings: [SKIPPED_MAPPING_WARNING], + }, { case: 'a property with no name is skipped and a repeated name is kept once with its last value, each naming which', body: externalTask( @@ -5635,10 +6088,13 @@ ${externalTask('', mapping)} properties: [{ key: 'k', value: 'w' }], }, warnings: [ - warning('Charge', /operaton:property #2.*no name/), warning( 'Charge', - /operaton:property 'k'.*written twice.*HashMap\.put/, + "'operaton:property' #2 of 'Charge' has no 'name' and was not imported", + ), + warning( + 'Charge', + "'operaton:property' 'k' of 'Charge' is written twice and was imported once\nnote: it keeps its last value, as 'BpmnParseUtil.parseOperatonExtensionProperties' keeps it ('HashMap.put'), at its first position", 'rewritten', ), ], @@ -5650,11 +6106,17 @@ ${extensionElements(`${propertiesOf(' `, binding: classBinding('com.example.Svc'), warnings: [ - warning('Svc', /'taskPriority'.*parseExternalServiceTask/), - warning('Svc', /operaton:properties.*parseExternalServiceTask/), warning( 'Svc', - /operaton:errorEventDefinition.*parseExternalServiceTask/, + "setting 'taskPriority' on 'Svc' was not imported\nnote: Operaton reads it in 'parseExternalServiceTask' alone, which only 'operaton:type=\"external\"' reaches\nnote: the step runs as written without it", + ), + warning( + 'Svc', + "'operaton:properties' block on 'Svc' was not imported\nnote: Operaton reads it in 'parseExternalServiceTask' alone, which only 'operaton:type=\"external\"' reaches\nnote: the step runs as written without it", + ), + warning( + 'Svc', + "'operaton:errorEventDefinition' on 'Svc' was not imported\nnote: Operaton reads it in 'parseExternalServiceTask' alone, which only 'operaton:type=\"external\"' reaches\nnote: the step runs as written without it", ), ], }, @@ -5671,7 +6133,10 @@ ${errorMapping('errorRef="Err_Declined" expression="${true}"')}`)} { @@ -5686,22 +6151,22 @@ ${body} `; const { ir, warnings } = await xmlToIr(xml); - if (binding !== undefined) { - const node = ir.flowElements.find( - (fe) => fe.kind === 'serviceTask' && fe.id !== 'Clean', - ); - expect(node?.kind === 'serviceTask' && node.binding).toEqual(binding); - } + const node = ir.flowElements.find( + (fe) => fe.kind === 'serviceTask' && fe.id !== 'Clean', + ); + expect(node?.kind === 'serviceTask' && node.binding).toEqual(binding); 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'), + await expectExtensionRefusal( + codedErrorsDoc(externalTask('operaton:taskPriority="1.5"', '')), + 'Charge', + '\'operaton:taskPriority="1.5"\' is not a whole number or an expression', + [ + "note: 'BpmnParse.parsePriority' refuses to deploy it (\"Value '1.5' for attribute 'taskPriority' is not a valid number\")", + ], ); - expect(err.elementId).toBe('Charge'); }); }); @@ -5709,27 +6174,13 @@ ${body} describe('xmlToIr: the extension-form refusal matrix', () => { const HOSTS = { Svc: serviceTaskWith, Review: userTaskWith }; - const refuse = ( - on: keyof typeof HOSTS, - children: string, - detail: RegExp | string, - ): Promise => - expectRefusal( - xmlToIr(HOSTS[on](children)), - UnsupportedExtensionFormError, - detail, - ); - const nested = (body: string): string => ioBlock(` ${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'; + const EMPTY_ATTRIBUTE = (attr: string): string => + `note: 'BpmnParse.parseExecutionListener' refuses it ("Attribute '${attr}' cannot be empty")`; it.each([ { @@ -5738,7 +6189,9 @@ ${body} children: nested(` `), - detail: /operaton:entry 'k'.*2 nested values/, + detail: + "'operaton:entry' 'k' in 'operaton:inputParameter' 'x' carries 2 nested values ('operaton:List', 'operaton:Map')", + notes: ['note: a value is one'], }, { case: 'an input parameter with no name', @@ -5746,9 +6199,8 @@ ${body} children: ioBlock( ` text`, ), - detail: - 'an operaton:inputParameter has no name, so there is nothing to bind ' + - 'its value to', + detail: "an 'operaton:inputParameter' has no name", + notes: ['note: without a name there is nothing to bind its value to'], }, { case: 'a map entry with no key', @@ -5756,7 +6208,8 @@ ${body} children: nested(` text `), - detail: /operaton:entry in .* has no key/, + detail: "an 'operaton:entry' in 'operaton:inputParameter' 'x' has no key", + notes: ['note: without a key there is nothing to look its value up by'], }, { case: 'a script value naming an external resource', @@ -5764,15 +6217,19 @@ ${body} children: nested( ' ', ), - detail: /external resource.*calc\.groovy/, + detail: + "the 'operaton:script' in 'operaton:inputParameter' 'x' names an external resource ('classpath://calc.groovy')", + notes: [ + "note: 'ScriptUtil.getScript' runs the resource in place of the body, and only an inline body can be written here", + ], }, { case: 'a script value with no scriptFormat', on: 'Svc', children: nested(' 1 + 1'), detail: - "the operaton:script in operaton:inputParameter 'x' has no " + - 'scriptFormat, so there is no language to evaluate its body in', + "the 'operaton:script' in 'operaton:inputParameter' 'x' has no 'scriptFormat'", + notes: ['note: without it there is no language to evaluate its body in'], }, { case: 'a listener script whose body holds three backticks', @@ -5781,9 +6238,8 @@ ${body} x = "\`\`\`" `, detail: - 'the operaton:script in an operaton:executionListener contains ' + - 'three consecutive backticks, which no script fence this language ' + - 'has can enclose', + "the 'operaton:script' in an 'operaton:executionListener' contains three consecutive backticks", + notes: ['note: no script fence in BPMNscript can enclose them'], }, { case: 'an operaton:entry inside an operaton:list', @@ -5792,9 +6248,11 @@ ${body} v `), detail: - "an operaton:list in operaton:inputParameter 'x' carries a " + - '; a list holds values, and an entry belongs in an ' + - 'operaton:map', + "an 'operaton:list' in 'operaton:inputParameter' 'x' carries a 'operaton:Entry'", + notes: [ + 'note: a list holds values', + "note: an entry belongs in an 'operaton:map'", + ], }, { case: 'an operaton:entry where a parameter value belongs', @@ -5803,8 +6261,8 @@ ${body} ` v`, ), detail: - "operaton:inputParameter 'x' carries a where a value " + - 'belongs; an entry belongs in an operaton:map', + "'operaton:inputParameter' 'x' carries a 'operaton:Entry' where a value belongs", + notes: ["note: an entry belongs in an 'operaton:map'"], }, { case: 'two input parameters sharing a name', @@ -5812,86 +6270,126 @@ ${body} children: ioBlock(` 1 2`), - detail: - /two operaton:inputParameter children share name="x"; Operaton runs both, the last write winning \(IoMapping\.executeInputParameters\)/, + detail: "two 'operaton:inputParameter' children share 'name=\"x\"'", + notes: [ + "note: Operaton runs both, the last write winning ('IoMapping.executeInputParameters')", + 'note: BPMNscript binds each parameter name once per direction', + ], }, - // 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'"), + detail: + "'operaton:inputParameter' 'my.param' names a variable the script cannot spell", + notes: [ + "note: a name is letters, digits and '_', with '-' between them, and no keyword", + 'note: the engine sets the variable under that name, so writing another would change what runs', + ], }, { case: 'an injected field naming no value slot', on: 'Svc', children: ` `, detail: "the injected field 'greeting' on 'Svc' names no value", + notes: [ONE_VALUE_SLOT], }, { case: 'an injected field naming an attribute and a child of the same slot', on: 'Svc', children: ` hi`, detail: - "the injected field 'greeting' on 'Svc' names a stringValue " + - 'attribute and an operaton:string child', + "the injected field 'greeting' on 'Svc' names a 'stringValue' attribute and an 'operaton:string' child", + notes: [ONE_VALUE_SLOT], }, { case: 'a listener carrying no binding at all', on: 'Svc', children: ` `, - detail: - 'an operaton:executionListener carries no binding: one of class, ' + - 'expression, delegateExpression, or an operaton:script child is ' + - 'what it runs', + detail: "an 'operaton:executionListener' carries no binding", + notes: [ + "note: it runs one of 'class', 'expression', 'delegateExpression', or an 'operaton:script' child", + ], }, { case: 'an execution listener with class=""', on: 'Svc', children: ` `, detail: - 'an operaton:executionListener has class="", which ' + - "BpmnParse.parseExecutionListener refuses (\"Attribute 'class' " + - 'cannot be empty")', + "an 'operaton:executionListener' has an empty class ('class=\"\"')", + notes: [EMPTY_ATTRIBUTE('class')], }, { case: 'an execution listener with delegateExpression=""', on: 'Svc', children: ` `, detail: - 'an operaton:executionListener has delegateExpression="", which ' + - 'BpmnParse.parseExecutionListener refuses ("Attribute ' + - "'delegateExpression' cannot be empty\")", + "an 'operaton:executionListener' has an empty delegate expression ('delegateExpression=\"\"')", + notes: [EMPTY_ATTRIBUTE('delegateExpression')], }, { case: 'a listener with no event', on: 'Svc', children: ` `, - detail: - 'an operaton:executionListener has no event, so there is no point ' + - 'in the lifecycle for it to fire at', + detail: "an 'operaton:executionListener' has no event", + notes: [ + 'note: without an event there is no point in the lifecycle for it to fire at', + ], }, { case: 'an execution listener whose event is neither start nor end', on: 'Svc', children: ` `, - detail: /event="take".*start, end/, + detail: + "an 'operaton:executionListener' has an event its position does not accept ('event=\"take\"')", + notes: ["note: the events it accepts are 'start' or 'end'"], }, { case: 'a timeout task listener with no timer', on: 'Review', children: ` `, detail: - 'an operaton:taskListener with event="timeout" carries no ' + - 'bpmn:timerEventDefinition, so nothing would ever fire it', + "an 'operaton:taskListener' with 'event=\"timeout\"' carries no 'bpmn:timerEventDefinition'", + notes: ['note: without one nothing would ever fire it'], + }, + { + // No listener binding: the timer is read before one is resolved. + case: 'a timeout task listener with two timers, since a timeout has one due time', + on: 'Review', + children: ` + + PT8H + + + PT1H + + `, + detail: + "an 'operaton:taskListener' with 'event=\"timeout\"' carries 2 'bpmn:timerEventDefinition' children", + notes: ['note: a timeout has one due time'], + }, + { + case: 'a second operaton:formData, since Element.elementNS throws on it', + on: 'Review', + children: [1, 2] + .map( + () => ` + + `, + ) + .join('\n'), + detail: "2 'operaton:formData' blocks", + notes: [ + "note: 'Element.elementNS' throws on them when 'DefaultFormHandler.parseFormData' reads them (\"Parsing exception: multiple elements with tag name 'formData' found\")", + "note: 'BpmnParse.execute' lets that fail the deployment", + ], }, ] as const)( '$case is refused, naming the shape and the element', - async (row) => { - const err = await refuse(row.on, row.children, row.detail); - expect(err.elementId).toBe(row.on); + async ({ on, children, detail, notes }) => { + await expectExtensionRefusal(HOSTS[on](children), on, detail, notes); }, ); }); @@ -5921,17 +6419,11 @@ describe('xmlToIr: task kinds', () => { }); const KINDS = [ - ['task', 'task', '', 'plain task'], - [ - 'sendTask', - 'serviceTask', - 'operaton:class="com.example.Send"', - 'send task', - ], - ['receiveTask', 'receiveTask', '', 'receive task'], + ['task', '', 'plain task'], + ['sendTask', 'operaton:class="com.example.Send"', 'send task'], + ['receiveTask', '', 'receive task'], [ 'businessRuleTask', - 'serviceTask', 'operaton:decisionRef="riskRating"', 'business rule task', ], @@ -5959,47 +6451,6 @@ describe('xmlToIr: task kinds', () => { `; - 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')), - UnsupportedServiceTaskFormError, - ); - expect(e.subject).toBe('Send task'); - expect(e.message).toContain( - "Send task 'T' uses unsupported execution form", - ); - }); - - it.each([ - [ - 'a mapDecisionResult outside the four Operaton accepts', - 'operaton:mapDecisionResult="firstEntry"', - 'operaton:mapDecisionResult="firstEntry", which is not a way of ' + - 'filling the result variable 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', - async (_title, attrs, construct) => { - const e = await expectRefusal( - xmlToIr( - oneNodeDoc('businessRuleTask', { - attrs: `operaton:decisionRef="d" ${attrs}`, - }), - ), - UnsupportedServiceTaskFormError, - ); - expect([e.serviceTaskId, e.construct]).toEqual(['T', construct]); - }, - ); - it('a receive task with a messageRef imports the message name, and the root it uses is not reported as unreferenced', async () => { const { node, warnings } = await importOnly( receiveDoc('messageRef="Msg_OrderPaid"', ORDER_PAID_ROOT), @@ -6011,33 +6462,30 @@ describe('xmlToIr: task kinds', () => { it.each(KINDS)( 'isForCompensation="true" on a bpmn:%s refuses rather than importing into normal flow', - async (tag, _kind, binding) => { - const e = await expectRefusal( - xmlToIr( - oneNodeDoc(tag, { attrs: `isForCompensation="true" ${binding}` }), - ), - UnsupportedEventFeatureError, - IS_FOR_COMPENSATION_DETAIL, + async (tag, binding) => { + await expectEventRefusal( + oneNodeDoc(tag, { attrs: `isForCompensation="true" ${binding}` }), + 'T', + IS_FOR_COMPENSATION, + EXCLUDED_FROM_FLOW, ); - expect(e.elementId).toBe('T'); }, ); it.each(KINDS)( 'an escalation boundary event on a bpmn:%s still refuses', - async (tag, _kind, binding, noun) => { - await expectRefusal( - xmlToIr( - boundaryDoc( - tag, - binding, - '', - ), + async (tag, binding, noun) => { + await expectEventRefusal( + boundaryDoc( + tag, + binding, + '', ), - UnsupportedEventFeatureError, - `an escalation boundary event attaches to "T", a ${noun}; ` + - 'Operaton only allows an escalation boundary on a subprocess, a ' + - 'call activity, or a user task', + 'B', + `an escalation boundary event attaches to 'T', a ${noun}`, + [ + 'note: Operaton only allows an escalation boundary on a subprocess, a call activity, or a user task', + ], ); }, ); @@ -6046,10 +6494,9 @@ describe('xmlToIr: task kinds', () => { elementId: 'M1', category: 'rewritten', message: - "The bpmn:manualTask 'M1' 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'.", + "manual task 'M1' imports as a plain step\n" + + 'note: token flow, waiting, listeners, async and job configuration are all unchanged\n' + + "note: history and Cockpit report its activity type as 'task' rather than 'manualTask'", }; it('a manual task imports as a plain step, warning that history will report it as a task', async () => { @@ -6085,13 +6532,10 @@ describe('xmlToIr: task kinds', () => { 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'.", + "intermediate throw event 'M' carries no event definition and imports as a plain step\n" + + "note: 'BpmnParse.parseIntermediateThrowEvent' gives it 'IntermediateThrowNoneEventActivityBehavior', which only leaves, as a task's behaviour does\n" + + 'note: token flow, listeners, async and job configuration are unchanged\n' + + "note: history and Cockpit report its activity type as 'task' rather than 'intermediateNoneThrowEvent'", }, ]); @@ -6156,19 +6600,19 @@ ${beside} 'a cancel end directly inside a plain subprocess', blockDoc({ tag: 'subProcess', body: BLOCK_BODY + CANCEL_END }), '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.', + 'an end event carries a cancel definition outside a block that can be given up\n' + + "note: Operaton only accepts one directly inside a 'bpmn:transaction', and " + + 'refuses to deploy the file otherwise\n' + + "help: move the end inside a 'bpmn:transaction', or take the cancel definition off it", ], [ '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 event attaches to 'Book', a subprocess\n" + + "note: Operaton only allows a cancel boundary on a 'bpmn:transaction', " + + 'and refuses to deploy the file otherwise\n' + + "help: attach it to a 'bpmn:transaction', or take the cancel definition off it", ], [ 'a second cancel boundary on the same block', @@ -6176,9 +6620,10 @@ ${beside} 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.', + "a second cancel boundary event attaches to 'Book'\n" + + 'note: Operaton allows one cancel boundary per block and refuses to ' + + 'deploy a file with two\n' + + 'help: leave one cancel boundary event on the block', ], ])('%s refuses', async (_title, xml, elementId, detail) => { const e = await expectRefusal( @@ -6187,7 +6632,7 @@ ${beside} ); expect(e.elementId).toBe(elementId); expect(e.message).toBe( - `The element '${elementId}' cannot be imported: ${detail}`, + `element '${elementId}' cannot be imported: ${detail}`, ); }); @@ -6196,38 +6641,36 @@ ${beside} 'a cancel end with no cancel boundary on its block warns, naming the runtime failure', blockDoc({ body: BLOCK_BODY + CANCEL_END }), 'Book', - "The block 'Book' holds an end event that gives it up, with no " + - 'cancel boundary event attached to it: Operaton deploys the file and ' + - 'then stops with an error the first time that end is reached. Write ' + - "'on Book: cancel { ... }' beside the block to catch it.", + "block 'Book' has an end event that gives it up, but no cancel boundary event\n" + + 'note: Operaton deploys the file, then stops with an error the first time that end is reached\n' + + "help: write 'on Book: cancel { ... }' beside the block to catch it", 'carriedAsWritten', ], [ 'a cancel boundary on a block that never gives itself up warns, naming the unreachable path', 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.', + "cancel boundary event on 'Book' can never fire\n" + + 'note: it was imported, but nothing inside the block gives it up\n' + + 'note: what follows the boundary can never run', 'carriedAsWritten', ], [ '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.', + "attribute 'method' on 'Book' was not imported\n" + + "note: Operaton does not read it on a 'bpmn:transaction'\n" + + 'note: the imported block runs exactly as the original document does', 'unmappedConstruct', ], [ '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.', + "attribute 'triggeredByEvent' on 'Book' was not imported\n" + + "note: Operaton ignores it on a 'bpmn:transaction' and runs the block as an ordinary step of the surrounding flow\n" + + 'note: the imported block runs exactly as the original document does', 'unmappedConstruct', ], ])('%s', async (_title, xml, elementId, message, category) => { @@ -6298,8 +6741,13 @@ ${routes} UnsupportedGatewayShapeError, detail, ); - expect(e.elementId).toBe('G'); - expect(e.message).toContain('BpmnParse.validateExclusiveGateway'); + expect([e.elementId, e.message]).toEqual([ + 'G', + "exclusive gateway 'G' cannot be imported\n" + + `note: Operaton's 'BpmnParse.validateExclusiveGateway' fails the deployment with "${detail}"\n` + + 'note: no running process exists to write back\n' + + "help: give every route but the fallback a condition, and name the fallback in the gateway's 'default'", + ]); }, ); }); @@ -6323,67 +6771,58 @@ ${element} `; - it('a default naming a flow that enters the step is dropped with the warning handleNoTransitions earns it', async () => { - const { node, warnings } = await importOnly( - stepDoc(' '), - 'userTask', - ); - expect(node).toEqual({ kind: 'userTask', id: 'Triage' }); - expect(warnings).toEqual([ - { - 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.', - }, - ]); + const once = + '2'; + const loop = { cardinality: '2' }; + const repeated = (named: string, routes: boolean) => ({ + elementId: 'Triage', + category: 'behaviourChanged', + message: + `repeated step 'Triage' names default flow '${named}' and can never complete\n` + + 'note: the engine hangs the routes out of a repeated step on the repetition around it, so a single run has no route to take as its default\n' + + "note: every run fails on completion with 'missingDefaultFlowException', whatever the conditions\n" + + (routes + ? `note: the rebuilt process takes '${named}' as the fallback of a split after the step, which completes` + : 'note: the imported step names no fallback and completes'), }); -}); - -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', - ); - const reachedTwice = await expectRefusal( - xmlToIr( - waitDoc({ - beside: ' \n', - extraFlows: - ' \n', - }), - ), - UnsupportedEventFeatureError, - ); - expect(reachedTwice.elementId).toBe('OnPaid'); - expect(reachedTwice.detail).toContain( - "'OnPaid' is reached by more than one path; a step inside a wait " + - 'with several branches can only be reached through the wait that ' + - 'opens it', - ); + it.each<[string, string, object, object[]]>([ + [ + 'a default naming a flow that enters the step is dropped with the warning handleNoTransitions earns it', + '', + { kind: 'userTask', id: 'Triage' }, + [ + { + elementId: 'Triage', + category: 'unmappedConstruct', + message: + "attribute 'default' on 'Triage' was not imported\n" + + "note: it names 'F0', which is not a route out of the step\n" + + "note: 'BpmnActivityBehavior.handleNoTransitions' finds no flow to take there and fails the step whenever no other route holds\n" + + 'note: the imported step names no fallback', + }, + ], + ], + [ + 'a repeated step keeps a default naming its own route and warns that the engine never takes it', + `${once}`, + { kind: 'userTask', id: 'Triage', loop, defaultFlowId: 'F2' }, + [repeated('F2', true)], + ], + [ + 'a repeated step drops a default naming no route of its own and warns that the original always fails', + `${once}`, + { kind: 'userTask', id: 'Triage', loop }, + [repeated('F0', false)], + ], + ])('%s', async (_title, element, node, warnings) => { + const imported = await importOnly(stepDoc(` ${element}`), 'userTask'); + expect(imported.node).toEqual(node); + expect(imported.warnings).toEqual(warnings); }); +}); +describe('xmlToIr: a wait with several branches', () => { interface WaitOptions { attrs?: string; children?: string; @@ -6443,411 +6882,161 @@ ${branches )}${extraFlows} `; - it('reports instantiate and eventGatewayType once each, and says nothing about gatewayDirection', async () => { - const { warnings: instantiate } = await xmlToIr( - waitDoc({ attrs: 'instantiate="true"' }), - ); - expectOneWarning(instantiate, { - elementId: 'Wait', - category: 'unmappedConstruct', - message: /The 'instantiate' attribute on 'Wait' was not imported/, - }); - expect(instantiate[0]!.message).toContain( - 'Operaton does not read it on a wait with several branches', - ); - - const { warnings: gatewayType } = await xmlToIr( - waitDoc({ attrs: 'eventGatewayType="Parallel"' }), - ); - expectOneWarning(gatewayType, { - elementId: 'Wait', - category: 'unmappedConstruct', - message: /The 'eventGatewayType' attribute on 'Wait' was not imported/, - }); - - const { warnings: quiet } = await xmlToIr( - waitDoc({ - attrs: - 'gatewayDirection="Diverging" instantiate="false" eventGatewayType="Exclusive"', - }), - ); - expect(quiet).toEqual([]); - }); - - it('a wait marked operaton:asyncAfter is refused, naming the engine rule', async () => { - const err = await expectRefusal( - xmlToIr( - 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(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.", - ); - }); -}); - -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.', - }); + const NOT_A_WAIT = [ + 'note: every branch of a wait with several branches has to begin with something to wait for', + 'note: only a message, a timer, a signal or a condition counts as one here', + 'help: begin each branch with a message, timer, signal or condition to wait for, and put the steps that follow it inside that branch', + ]; - const rows: [title: string, run: () => Promise][] = [ + it.each([ [ - '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' }, + 'a branch that does not begin with something to wait for', + waitDoc({ + branches: [ + messageBranch, + { id: 'Ship', element: '' }, ], - ] 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', - ); - }, + }), + 'Ship', + "a branch of the wait 'Wait' leads to 'Ship', a user task", + NOT_A_WAIT, ], [ - 'a single operaton:value on a non-enum field warns in the singular', - async () => { - const { warnings } = await importForm( - ` - - `, - ); - expect(warnings).toEqual([ + 'a branch that leads straight into a bpmn:transaction, named "an attempt block"', + waitDoc({ + branches: [ + timerBranch, { - 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.', + id: 'Attempt', + element: ` + + + + `, }, - ]); - }, + ], + }), + 'Attempt', + "a branch of the wait 'Wait' leads to 'Attempt', an attempt block", + NOT_A_WAIT, ], [ - '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 branch reached by more than one path', + waitDoc({ + beside: ' \n', + extraFlows: + ' \n', + }), + 'OnPaid', + "'OnPaid' is reached by more than one path", + [ + 'note: a step inside a wait with several branches can only be reached through the wait that opens it', + "help: leave 'OnPaid' with the one path into it that starts at 'Wait', and lead the rest elsewhere", + ], ], [ - '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 wait marked operaton:asyncAfter, naming the engine rule', + waitDoc({ attrs: 'operaton:asyncAfter="true"', defs: operatonDefs }), + 'Wait', + "'Wait' is marked 'asyncAfter'", + [ + "note: 'BpmnParse.parseEventBasedGateway' refuses to deploy it on a wait with several branches", + "help: drop the 'asyncAfter' setting from 'Wait'", + 'note: its other job settings are read as written', + ], ], + ] as const)('%s refuses', async (_title, xml, elementId, detail, notes) => { + await expectEventRefusal(xml, elementId, detail, notes); + }); + + const unreadOnWait = (attr: string): ImportWarning => ({ + elementId: 'Wait', + category: 'unmappedConstruct', + message: + `attribute '${attr}' on 'Wait' was not imported\n` + + 'note: Operaton does not read it on a wait with several branches\n' + + 'note: the imported process runs exactly as the original document does', + }); + + it.each([ + ['instantiate="true" is reported', 'instantiate="true"', ['instantiate']], [ - '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', - ' }', - '}', - ]); - }, + 'eventGatewayType="Parallel" is reported', + 'eventGatewayType="Parallel"', + ['eventGatewayType'], ], [ - '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', - ); - }, + 'gatewayDirection, and instantiate and eventGatewayType at their defaults, say nothing', + 'gatewayDirection="Diverging" instantiate="false" eventGatewayType="Exclusive"', + [], ], + ])('%s', async (_title, attrs, reported) => { + const { warnings } = await xmlToIr(waitDoc({ attrs })); + expect(warnings).toEqual(reported.map(unreadOnWait)); + }); +}); + +describe('xmlToIr: error and message roots', () => { + const rootsDoc = (roots: string): string => + rootedDoc( + roots, + ` + `, + operatonDefs, + ); + + it.each([ + ['both messageless', '', '', { name: 'DUP', code: 'DUP' }], [ - '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'), - ]); - }, + 'only the second carrying a message', + '', + 'operaton:errorMessage="Oops"', + { name: 'DUP', code: 'DUP', message: 'Oops' }, ], [ - '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).', - }, - ]); - }, + 'agreeing on the message', + 'operaton:errorMessage="Oops"', + 'operaton:errorMessage="Oops"', + { name: 'DUP', code: 'DUP', message: 'Oops' }, ], + ])( + 'two bpmn:Error roots sharing a code, %s, merge into one declaration', + async (_shape, first, second, decl) => { + const { ir, warnings } = await xmlToIr( + rootsDoc( + ` \n` + + ` \n`, + ), + ); + expect(ir.errorDecls).toEqual([decl]); + expect(warnings).toEqual([]); + }, + ); + + it.each([ [ - '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}`, - ); - }, + 'an unnamed, unreferenced bpmn:message root', + ' \n', + 'M1', + "message root 'M1' has no name and was not imported\n" + + 'note: without a name it cannot be keyed or represented in the script', ], [ - '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)', - ); - }, + 'an error root with no code', + ' \n', + 'Error_NoCode', + "error root 'Error_NoCode' has no code and was not imported\n" + + 'note: without a code it cannot be keyed or represented in the script', ], - ]; - - it.each(rows)('%s', async (_title, run) => { - await run(); - }); + ])( + '%s warns that it cannot be keyed, and is dropped', + async (_title, roots, id, message) => { + const { ir, warnings } = await xmlToIr(rootsDoc(roots)); + expect([ir.errorDecls, ir.escalationDecls, warnings]).toEqual([ + undefined, + undefined, + [{ elementId: id, category: 'unreferencedRoot', message }], + ]); + }, + ); }); diff --git a/tests/backtick-names.round-trip.test.ts b/tests/backtick-names.round-trip.test.ts new file mode 100644 index 0000000..e6d0e0b --- /dev/null +++ b/tests/backtick-names.round-trip.test.ts @@ -0,0 +1,131 @@ +// An engine id the plain name cannot spell imports, prints in backticks and +// compiles back to the same id; the engine keys history, migration and +// start-before-activity on it, so a renamed id would not be the same process. + +import { describe, it, expect } from 'vitest'; + +import { astToIr, irToDsl, irToXml, xmlToIr } from '@bpmn-script/transform'; +import type { BpmnProcess, FlowContainer } from '@bpmn-script/transform'; + +import { compareModels } from './helpers/model-equivalence.js'; +import { parseToAst, validate } from './helpers/pipeline.js'; + +interface Ids { + process: string; + start: string; + task: string; + end: string; +} + +const PLAIN: Ids = { process: 'P', start: 'S', task: 'T', end: 'E' }; + +/** + * The timer loops back to its host, so `task` is an attach and a goto target + * too. The boundary carries the id the compiler derives from its host, since a + * handler has no name to keep its own. + */ +function document(ids: Ids): string { + return ( + '\n' + + '\n' + + ` \n` + + ` \n` + + ` \n` + + ` \n` + + ' PT1H\n' + + ' \n' + + ` \n` + + ` \n` + + ` \n` + + ` \n` + + ' \n' + + '' + ); +} + +function script(names: Ids): string { + return ( + `process ${names.process} {\n` + + ` start ${names.start}\n` + + ` user ${names.task}(assignee: "demo")\n` + + ` end ${names.end}\n` + + ` on ${names.task}: timer("PT1H") {\n` + + ` goto ${names.task}\n` + + ' }\n' + + '}\n' + ); +} + +function flowNodeIds(container: FlowContainer): string[] { + return container.flowElements.flatMap((el) => [ + el.id, + ...('flowElements' in el ? flowNodeIds(el) : []), + ]); +} + +async function compiled(source: string): Promise { + const xml = await irToXml(astToIr(await parseToAst(source))); + return (await xmlToIr(xml)).ir; +} + +describe('an id the plain form cannot spell round-trips in backticks', () => { + it.each<[title: string, ids: Partial, printed: Partial]>([ + ['a plain id prints plain', {}, {}], + ['a keyword process id', { process: 'process' }, { process: '`process`' }], + ['a keyword start id', { start: 'start' }, { start: '`start`' }], + ['a keyword end id', { end: 'end' }, { end: '`end`' }], + ['a keyword task id', { task: 'var' }, { task: '`var`' }], + ['a dotted task id', { task: 'Task.1' }, { task: '`Task.1`' }], + ['a trailing hyphen', { task: 'Review-' }, { task: '`Review-`' }], + ['a doubled hyphen', { task: 'a--b' }, { task: '`a--b`' }], + ['a hyphen before a dot', { task: '_x-.y' }, { task: '`_x-.y`' }], + ])('%s', async (_title, ids, printed) => { + const imported = await xmlToIr(document({ ...PLAIN, ...ids })); + expect(imported.warnings).toEqual([]); + + const { source, warnings } = irToDsl(imported.ir); + expect(warnings).toEqual([]); + expect(source).toBe(script({ ...PLAIN, ...printed })); + expect((await validate(source)).diagnostics).toEqual([]); + + const rebuilt = await compiled(source); + expect([rebuilt.id, ...flowNodeIds(rebuilt).sort()]).toEqual([ + imported.ir.id, + ...flowNodeIds(imported.ir).sort(), + ]); + expect(compareModels(imported.ir, rebuilt).differences).toEqual([]); + }); + + it('an error name with a space is kept as the declared name', async () => { + const xml = + '\n' + + '\n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ''; + const imported = await xmlToIr(xml); + const { source, warnings } = irToDsl(imported.ir); + expect([imported.warnings, warnings]).toEqual([[], []]); + expect(source).toBe( + 'process P {\n' + + ' error `Payment failed`(code: "PF")\n' + + ' start S\n' + + ' throw error E(`Payment failed`)\n' + + '}\n', + ); + expect((await validate(source)).diagnostics).toEqual([]); + expect((await compiled(source)).errorDecls).toEqual(imported.ir.errorDecls); + }); + + it('an id with a colon is refused on import, since Operaton refuses it', async () => { + await expect( + xmlToIr(document({ ...PLAIN, task: 'ns:Task' })), + ).rejects.toThrow("the id 'ns:Task' is not an id Operaton deploys"); + }); +}); diff --git a/tests/boundary-events.round-trip.test.ts b/tests/boundary-events.round-trip.test.ts index 04595a3..7ac9b4f 100644 --- a/tests/boundary-events.round-trip.test.ts +++ b/tests/boundary-events.round-trip.test.ts @@ -257,9 +257,31 @@ describeImportFirst( IMPORT_FIRST_BPMN, (first) => { 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) {'); + expect(first.dsl).toBe( + [ + 'process crate-handover {', + ' error TORN_BOX', + ' error MISSING_ITEM', + ' start CrateArrived', + ' user InspectCrate(label: "Inspect the crate", assignee: "demo")', + ' service StoreCrate(label: "Store the crate", class: "com.example.dispatch.StoreDelegate")', + ' end CrateAccepted', + ' on InspectCrate: error(TORN_BOX) {', + ' service RepackCrate(label: "Repack the crate", class: "com.example.dispatch.RepackDelegate")', + ' end CrateRepacked', + ' }', + ' on InspectCrate: error(MISSING_ITEM) {', + ' service ReorderItem(label: "Reorder the missing item", class: "com.example.dispatch.ReorderDelegate")', + ' end ItemReordered', + ' }', + ' on StoreCrate: timer("PT1H", alongside) {', + ' service ChaseStorage(label: "Chase the storage team", class: "com.example.dispatch.ChaseDelegate")', + ' end StorageChased', + ' }', + '}', + '', + ].join('\n'), + ); // The `_2` suffix follows print order. expect(boundaryEvents(first.ir).map((b) => b.id)).toEqual([ 'BoxTorn', diff --git a/tests/branch-and-race.round-trip.test.ts b/tests/branch-and-race.round-trip.test.ts index ebae44d..dcc3762 100644 --- a/tests/branch-and-race.round-trip.test.ts +++ b/tests/branch-and-race.round-trip.test.ts @@ -232,20 +232,53 @@ it('prints a fork fallback that can never fire as an else the validator refuses, 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(source).toBe( + [ + 'process dead-fallback {', + ' var orderValue: any', + ' start Start_1', + ' parallel {', + ' if (orderValue > 10000) {', + ' user AuditOrder', + ' }', + ' {', + ' user ReserveStock', + ' }', + ' else {', + ' user TriageOrder', + ' }', + ' }', + ' end End_1', + '}', + '', + ].join('\n'), + ); + expect(warnings).toEqual([ + { + elementId: 'Gateway_1_fork', + category: 'defaultFlow', + message: [ + "fallback of gateway 'Gateway_1_fork' can never run", + 'note: a branch beside it runs whatever the conditions do, so nothing is ever left over for the fallback to take', + "note: the script writes it out as an 'else' all the same, which draws an error when the source is read back", + 'help: put a condition on the branches beside it, or drop the fallback', + ].join('\n'), + }, ]); - expect(warnings[0]?.message).toContain('nothing is ever left over'); expect(await validationErrors(source)).toEqual([ - expect.stringContaining('could never run'), + [ + "this 'else' branch can never run", + "note: an 'else' runs only when no sibling branch was taken, and a branch with no condition is always taken", + "help: give every sibling a condition, or remove the 'else'", + ].join('\n'), ]); const merged = irToDsl( (await xmlToIr(await irToXml(inclusiveForkIr('Gateway_1_join')))).ir, ); - expect(merged.source).not.toContain('else'); + expect(merged.source).toBe( + source.replace(' else {\n user TriageOrder\n }\n', ''), + ); 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 c765477..d14bd7a 100644 --- a/tests/call-activity.round-trip.test.ts +++ b/tests/call-activity.round-trip.test.ts @@ -353,15 +353,21 @@ describeImportFirst( 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' + + expect(first.dsl).toBe( + [ + 'process call-import-demo {', + ' var amount: any', + ' start Start', + ' call ReviewApprovalCall(label: "Get invoice sign-off", process: "invoice-approval", binding: deployment, businessKey: "${orderId}") {', + ' in invoiceAmount = amount', + ' in doubledAmount = "${amount * 2}"', + ' out wasApproved = approved', + ' out approvedFlag = "${approved ? 1 : 0}"', ' }', + ' end End', + '}', + '', + ].join('\n'), ); }); }, diff --git a/tests/comparison-report.test.ts b/tests/comparison-report.test.ts new file mode 100644 index 0000000..6672918 --- /dev/null +++ b/tests/comparison-report.test.ts @@ -0,0 +1,189 @@ +import { execFileSync } from 'node:child_process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import { + HISTORY_TIME_TO_LIVE, + xmlToIr, + type BpmnProcess, +} from '@bpmn-script/transform'; + +import { bpmnDoc } from './helpers/bpmn-doc.js'; +import { + compareModels, + type ModelReport, +} from './helpers/model-equivalence.js'; +import { printDsl, roundTrip } from './helpers/pipeline.js'; + +type Pair = [BpmnProcess, BpmnProcess]; + +const flow = (id: string, from: string, to: string) => + ` \n`; + +// Elements are listed in the order the compiler emits them, so only the ids differ. +// The race has no id slot in the script, so the catch ids cannot survive the printer. +const IMPORTED_RACE = bpmnDoc( + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' PT1H\n' + + ' \n' + + ' \n' + + ' \n' + + flow('Flow_2', 'Gateway_0xyz', 'Event_0abc') + + flow('Flow_4', 'Event_0abc', 'Activity_Pay') + + flow('Flow_6', 'Activity_Pay', 'Gateway_Merge') + + flow('Flow_3', 'Gateway_0xyz', 'Event_1def') + + flow('Flow_5', 'Event_1def', 'Activity_Expire') + + flow('Flow_7', 'Activity_Expire', 'Gateway_Merge') + + flow('Flow_1', 'StartEvent_1', 'Gateway_0xyz') + + flow('Flow_8', 'Gateway_Merge', 'Event_End'), + ' \n', +); + +describe('comparison report through the real pipeline', () => { + const header = (ttl?: string) => + ttl === undefined ? '' : `(historyTimeToLive: "${ttl}")`; + const script = (ttl?: string) => + `process p${header(ttl)} {\n start S\n user T(assignee: "demo")\n end E\n}\n`; + const fromScript = async (ttl?: string): Promise => { + const run = await roundTrip(script(ttl)); + return [run.ir1, run.ir3]; + }; + const imported = async (ttl?: string) => + ( + await xmlToIr( + bpmnDoc( + ' \n' + + ' \n' + + flow('Flow_S_E', 'S', 'E'), + ).replace( + ` operaton:historyTimeToLive="${HISTORY_TIME_TO_LIVE}"`, + ttl === undefined ? '' : ` operaton:historyTimeToLive="${ttl}"`, + ), + ) + ).ir; + const fromXml = async (ttl?: string): Promise => { + const original = await imported(ttl); + return [original, (await roundTrip(printDsl(original))).ir3]; + }; + const unchanged: ModelReport = { + level: 'same', + normalizations: [], + differences: [], + }; + + it.each<[string, () => Promise, ModelReport]>([ + [ + 'XML without a history time to live gains the default, reported as the default-history-time-to-live normalization', + () => fromXml(), + { + level: 'same', + normalizations: [ + { names: ['default-history-time-to-live'], elements: ['p'] }, + ], + differences: [], + }, + ], + [ + 'XML with the default history time to live keeps it with nothing reported', + () => fromXml(HISTORY_TIME_TO_LIVE), + unchanged, + ], + [ + 'XML with a non-default history time to live keeps it with nothing reported', + () => fromXml('P7D'), + unchanged, + ], + [ + 'a script without a history time to live round-trips with nothing reported', + () => fromScript(), + unchanged, + ], + [ + 'a script with the default history time to live round-trips with nothing reported', + () => fromScript(HISTORY_TIME_TO_LIVE), + unchanged, + ], + [ + 'a rebuild that drops an original default history time to live is a difference', + async () => [await imported(HISTORY_TIME_TO_LIVE), await imported()], + { + level: 'changed', + normalizations: [], + differences: [ + { + container: 'p', + element: 'p', + fields: [{ path: 'historyTimeToLive', a: HISTORY_TIME_TO_LIVE }], + }, + ], + }, + ], + ])('%s', async (_, pair, expected) => { + const [a, b] = await pair(); + expect(compareModels(a, b)).toEqual(expected); + }); + + it('an imported race whose catch events carry modeler ids round-trips with zero remaining differences and names the synthesized-ids normalization', async () => { + const { ir: imported } = await xmlToIr(IMPORTED_RACE); + const reread = (await roundTrip(printDsl(imported))).ir3; + expect(compareModels(imported, reread)).toEqual({ + level: 'canonical', + normalizations: [ + { + names: ['synthesized-ids'], + elements: [ + 'p/Catch_p_1_b0', + 'p/Catch_p_1_b1', + 'p/Event_0abc', + 'p/Event_1def', + 'p/Flow_1', + 'p/Flow_2', + 'p/Flow_3', + 'p/Flow_4', + 'p/Flow_5', + 'p/Flow_6', + 'p/Flow_7', + 'p/Flow_Activity_Expire_Gateway_p_1_join', + 'p/Flow_Activity_Pay_Gateway_p_1_join', + 'p/Flow_Catch_p_1_b0_Activity_Pay', + 'p/Flow_Catch_p_1_b1_Activity_Expire', + 'p/Flow_Gateway_p_1_race_Catch_p_1_b0', + 'p/Flow_Gateway_p_1_race_Catch_p_1_b1', + 'p/Flow_StartEvent_1_Gateway_p_1_race', + 'p/Gateway_0xyz', + 'p/Gateway_p_1_race', + ], + }, + ], + differences: [], + }); + }); + + it('the helper and the generator load and run outside vitest', () => { + const url = (file: string) => + pathToFileURL( + fileURLToPath(new URL(`./helpers/${file}`, import.meta.url)), + ).href; + const script = + `const { compareModels } = await import(${JSON.stringify(url('model-equivalence.ts'))});\n` + + `const { generateProgram, renderProgram } = await import(${JSON.stringify(url('fuzz-generator.ts'))});\n` + + `const p = { id: 'p', flowElements: [], sequenceFlows: [] };\n` + + `console.log(JSON.stringify({ report: compareModels(p, p), generated: renderProgram(generateProgram(1)).length > 0 }));\n`; + const out = execFileSync( + process.execPath, + ['--input-type=module', '-e', script], + { encoding: 'utf8' }, + ); + expect(JSON.parse(out)).toEqual({ + report: { level: 'same', normalizations: [], differences: [] }, + generated: true, + }); + }); +}); diff --git a/tests/compensation.round-trip.test.ts b/tests/compensation.round-trip.test.ts index b12b447..6f249a0 100644 --- a/tests/compensation.round-trip.test.ts +++ b/tests/compensation.round-trip.test.ts @@ -109,12 +109,26 @@ describeImportFirst( IMPORT_FIRST_BPMN, (first) => { it('recovers each compensation surface into the DSL', () => { - expect(first.dsl).toContain( - 'subprocess Pick(label: "Pick the whole order") {', + expect(first.dsl).toBe( + [ + 'process warehouse-fulfilment {', + ' start Begin', + ' subprocess Pick(label: "Pick the whole order") {', + ' start PickBegin', + ' service Grab(label: "Take the item off the shelf", class: "com.example.GrabDelegate")', + ' end PickDone', + ' on compensation {', + ' start UndoPick', + ' service PutBack(label: "Return the item to the shelf", class: "com.example.PutBackDelegate")', + ' end UndoDone', + ' }', + ' }', + ' emit compensation Raise', + ' throw compensation GiveUp', + '}', + '', + ].join('\n'), ); - expect(first.dsl).toContain('on compensation {'); - expect(first.dsl).toContain('emit compensation Raise'); - expect(first.dsl).toContain('throw compensation GiveUp'); 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 deleted file mode 100644 index 9cad617..0000000 --- a/tests/cyclic-loop-performance.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -// `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/e2e/claim-routing.test.ts b/tests/e2e/claim-routing.test.ts new file mode 100644 index 0000000..55eb02a --- /dev/null +++ b/tests/e2e/claim-routing.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeAll } from 'vitest'; + +import type { FixtureAdapter } from '../fixtures/index.js'; +import { + deployExamples, + DEPLOY_TIMEOUT_MS, + SKIP_DOCKER as SKIP, +} from '../helpers/e2e-fixture.js'; +import { waitForTaskId, waitForTaskKeys } from '../helpers/engine-rest.js'; + +const PROCESS_KEY = 'claim-routing'; + +const SPLIT = 'Gateway_claim-routing_1_split'; +const FORK = 'Gateway_claim-routing_2_fork'; + +// `BpmnBehaviorLogger.stuckExecutionException`, thrown by both gateway kinds +// when no outgoing flow is taken and none is the default. +const stuck = (gatewayId: string): RegExp => + new RegExp( + `ENGINE-02004 No outgoing sequence flow for the element with id '${gatewayId}' could be selected for continuing the process`, + ); + +describe.skipIf(SKIP)("E2E: 'else fail' on Spring Boot Operaton", () => { + let fixture: FixtureAdapter; + + beforeAll(async () => { + fixture = await deployExamples(PROCESS_KEY); + }, DEPLOY_TIMEOUT_MS); + + // The raw call, since the adapter throws before the status can be read. + async function post( + path: string, + variables: Record, + ): Promise<{ status: number; body: string }> { + const response = await fetch( + `${fixture.restBaseUrl()}/engine-rest${path}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ variables }), + }, + ); + return { status: response.status, body: await response.text() }; + } + + it('runs the route whose condition holds, and the fork the branch whose condition holds', async () => { + const { processInstanceId } = await fixture.startProcess(PROCESS_KEY, { + amount: 200, + injury: true, + }); + await fixture.completeTask( + await waitForTaskId(fixture, processInstanceId, 'QuickReview'), + ); + + expect( + await waitForTaskKeys( + fixture, + processInstanceId, + (keys) => keys.length > 0 && !keys.includes('QuickReview'), + ), + ).toEqual(['MedicalCheck']); + }, 60_000); + + it('stops the start with the engine error when no condition of the exclusive split holds', async () => { + const started = await post(`/process-definition/key/${PROCESS_KEY}/start`, { + amount: { value: 99999 }, + injury: { value: false }, + }); + + expect({ + status: started.status, + stuck: stuck(SPLIT).test(started.body), + }).toEqual({ status: 500, stuck: true }); + }, 60_000); + + it('refuses to complete the task before the inclusive fork when no branch condition holds, and leaves the task open', async () => { + const { processInstanceId } = await fixture.startProcess(PROCESS_KEY, { + amount: 200, + injury: false, + }); + const taskId = await waitForTaskId( + fixture, + processInstanceId, + 'QuickReview', + ); + + const completed = await post(`/task/${taskId}/complete`, {}); + + expect({ + status: completed.status, + stuck: stuck(FORK).test(completed.body), + open: (await fixture.getActiveTasks(processInstanceId)).map( + (task) => task.taskDefinitionKey, + ), + }).toEqual({ status: 500, stuck: true, open: ['QuickReview'] }); + }, 60_000); +}); diff --git a/tests/e2e/event-positions.test.ts b/tests/e2e/event-positions.test.ts index 4f5a689..65ead53 100644 --- a/tests/e2e/event-positions.test.ts +++ b/tests/e2e/event-positions.test.ts @@ -81,9 +81,8 @@ describe.skipIf(SKIP)('E2E: start, end, throw and link on Operaton', () => { ).toContain('ConfirmOrder'); }, 60_000); - // Neither thrown message carries an implementation, so the question the - // engine answers here is whether it passes the token through both or refuses - // the definition outright. + // Both thrown messages are bound to the demo's logging delegate, so the + // engine runs it on each and carries the token on through both. it('message throw and message end: the token passes through both and the instance ends', async () => { const processInstanceId = await startByMessage(fixture, 'OrderReceived'); await fixture.completeTask( diff --git a/tests/e2e/implicit-routing.test.ts b/tests/e2e/implicit-routing.test.ts new file mode 100644 index 0000000..8b15a8f --- /dev/null +++ b/tests/e2e/implicit-routing.test.ts @@ -0,0 +1,438 @@ +// The routing a model can leave to its nodes, run on Operaton next to the same +// model with the end event or gateway written out: what the comparison +// helper's `implicit-end` and `uncontrolled-flow` take as equal must behave +// the same, and what they keep apart must not. + +import { describe, it, expect, beforeAll } from 'vitest'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { xmlToIr } from '@bpmn-script/transform'; + +import type { FixtureAdapter } from '../fixtures/index.js'; +import { startFixture } from '../fixtures/index.js'; +import { + DEPLOY_TIMEOUT_MS, + SKIP_DOCKER as SKIP, +} from '../helpers/e2e-fixture.js'; +import { + activeTaskKeys, + isRunning, + jobDefinitionsFor, + jobsOfInstance, + setJobDefinitionSuspended, + waitFor, + waitForTaskId, +} from '../helpers/engine-rest.js'; +import { compareModels } from '../helpers/model-equivalence.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// `kind id attributes` per node, `|` before a child element; `a -> b ? cond` +// per flow, its id `f_a_b`. +type Model = { nodes: string[]; flows: string[] }; + +const ASYNC_AFTER = 'operaton:asyncAfter="true"'; +const ONE_RUN = + '1'; + +const escape = (text: string): string => + text.replace(/&/g, '&').replace(//g, '>'); + +function processXml(id: string, { nodes, flows }: Model): string { + const elements = nodes.map((node) => { + const [head, child = ''] = node.split(' | '); + const [kind, nodeId, ...attributes] = head.split(' '); + return `<${kind} id="${nodeId}" ${attributes.join(' ')}>${child}`; + }); + const sequenceFlows = flows.map((flow) => { + const [, source, target, condition] = /^(\S+) -> (\S+)(?: \? (.+))?$/.exec( + flow, + )!; + const body = + condition === undefined + ? '' + : `${escape(condition)}`; + return `${body}`; + }); + return ` +${[...elements, ...sequenceFlows].join('\n')} +`; +} + +const START = 'startEvent S'; +const T = 'userTask T'; +const tasks = (...ids: string[]) => ids.map((id) => `userTask ${id}`); +// T leaving for A on `x > 1`, B on `x > 2`, and D by default, through an +// inclusive fork when `fork`; `child` goes into T. +const conditional = (fork: boolean, child = ''): Model => { + const from = fork ? 'G' : 'T'; + const fallback = `default="f_${from}_D"`; + return { + nodes: [ + START, + `${T} ${fork ? '' : fallback}${child}`, + ...(fork ? [`inclusiveGateway G ${fallback}`] : []), + ...tasks('A', 'B', 'D'), + ], + flows: [ + 'S -> T', + ...(fork ? ['T -> G'] : []), + `${from} -> A ? \${x > 1}`, + `${from} -> B ? \${x > 2}`, + `${from} -> D`, + ], + }; +}; +// T leaving for A and B on the conditions given, through a `fork` gateway when +// one is named; `extra` adds attributes to T. +const twoWay = ( + fork: string | undefined, + [toA, toB]: (string | undefined)[], + extra = '', +): Model => { + const from = fork === undefined ? 'T' : 'G'; + const on = (cond?: string) => (cond === undefined ? '' : ` ? ${cond}`); + return { + nodes: [ + START, + `${T} ${extra}`, + ...(fork === undefined ? [] : [`${fork} G`]), + ...tasks('A', 'B'), + ], + flows: [ + 'S -> T', + ...(fork === undefined ? [] : ['T -> G']), + `${from} -> A${on(toA)}`, + `${from} -> B${on(toB)}`, + ], + }; +}; +const BOTH = ['${x > 1}', '${x > 2}']; +// A fork into A and a sub-process P that throws at once, caught on P and led +// away to E2, both kinds `kind`, joined before C. +const diverted = (kind: string): Model => ({ + nodes: [ + START, + `${kind} F`, + 'userTask A', + 'subProcess P | ', + 'boundaryEvent X attachedToRef="P" | ', + `${kind} J`, + 'userTask C', + 'endEvent E2', + ], + flows: [ + 'S -> F', + 'F -> A', + 'F -> P', + 'A -> J', + 'P -> J', + 'J -> C', + 'X -> E2', + ], +}); + +interface Pair { + key: string; + direct: Model; + // Where the end event is minted, its id names the process `${key}-x`. + written: Model; + // Names the comparison helper needs, or `changed` where it keeps them apart. + verdict: string[][] | 'changed'; +} + +const PAIRS: Pair[] = [ + { + key: 'implicit-end', + direct: { nodes: [START, T], flows: ['S -> T'] }, + written: { + nodes: [START, T, 'endEvent EndEvent_implicit-end-x'], + flows: ['S -> T', 'T -> EndEvent_implicit-end-x'], + }, + verdict: [['synthesized-ids'], ['implicit-end']], + }, + { + key: 'implicit-end-async', + direct: { nodes: [START, `${T} ${ASYNC_AFTER}`], flows: ['S -> T'] }, + written: { + nodes: [ + START, + `${T} ${ASYNC_AFTER}`, + 'endEvent EndEvent_implicit-end-async-x', + ], + flows: ['S -> T', 'T -> EndEvent_implicit-end-async-x'], + }, + verdict: [['synthesized-ids'], ['implicit-end']], + }, + { + key: 'implicit-merge', + direct: { + nodes: [START, 'parallelGateway F', ...tasks('A', 'B', 'C')], + flows: ['S -> F', 'F -> A', 'F -> B', 'A -> C', 'B -> C'], + }, + written: { + nodes: [ + START, + 'parallelGateway F', + ...tasks('A', 'B', 'C'), + 'exclusiveGateway M', + ], + flows: ['S -> F', 'F -> A', 'F -> B', 'A -> M', 'B -> M', 'M -> C'], + }, + verdict: [['synthesized-ids'], ['uncontrolled-flow']], + }, + { + key: 'implicit-split', + direct: twoWay(undefined, []), + written: twoWay('parallelGateway', []), + verdict: [['synthesized-ids'], ['uncontrolled-flow']], + }, + { + key: 'conditional-split', + direct: conditional(false), + written: conditional(true), + verdict: [['synthesized-ids'], ['uncontrolled-flow']], + }, + { + key: 'conditional-split-no-default', + direct: twoWay(undefined, BOTH), + written: twoWay('inclusiveGateway', BOTH), + verdict: [['synthesized-ids'], ['uncontrolled-flow']], + }, + { + key: 'mixed-split', + direct: twoWay(undefined, ['${x > 1}']), + written: twoWay('inclusiveGateway', ['${x > 1}']), + verdict: [['synthesized-ids'], ['uncontrolled-flow']], + }, + { + key: 'split-async', + direct: twoWay(undefined, [], ASYNC_AFTER), + written: twoWay('parallelGateway', [], ASYNC_AFTER), + verdict: 'changed', + }, + { + key: 'diverted-join', + direct: diverted('inclusiveGateway'), + written: diverted('parallelGateway'), + verdict: 'changed', + }, + { + key: 'loop-default', + direct: conditional(false, ` | ${ONE_RUN}`), + written: conditional(true, ` | ${ONE_RUN}`), + verdict: 'changed', + }, +]; + +// What a run left after each step: open tasks, jobs, whether the instance +// still runs, and whether the engine refused the step. +interface Observation { + tasks: string[]; + jobs: number; + running: boolean; + refused?: true; +} + +describe.skipIf(SKIP)('E2E: routing left to the nodes on Operaton', () => { + let fixture: FixtureAdapter; + + // Element ids repeat across the processes, so each goes in its own document. + beforeAll(async () => { + fixture = startFixture(); + for (const [id, model] of PAIRS.flatMap(({ key, direct, written }) => [ + [key, direct] as const, + [`${key}-x`, written] as const, + ])) { + const xmlPath = resolve( + __dirname, + `../../out/implicit-routing/${id}.bpmn`, + ); + mkdirSync(dirname(xmlPath), { recursive: true }); + writeFileSync(xmlPath, wrap(processXml(id, model))); + const { deploymentId } = await fixture.deploy(xmlPath, `${id}-test`); + expect(deploymentId).toBeTruthy(); + } + }, DEPLOY_TIMEOUT_MS); + + // A suspended job definition holds each asyncAfter job for counting. + async function run( + key: string, + variables: Record, + steps: string[], + ): Promise { + const holds = await jobDefinitionsFor(fixture, key, 'T'); + for (const { id } of holds) { + await setJobDefinitionSuspended(fixture, id, true); + } + const { processInstanceId } = await fixture.startProcess(key, variables); + const observations: Observation[] = []; + for (const step of steps) { + let refused = false; + if (step === 'resume') { + for (const { id } of holds) { + await setJobDefinitionSuspended(fixture, id, false); + } + await waitFor( + () => isRunning(fixture, processInstanceId), + (running) => !running, + ); + } else { + const taskId = await waitForTaskId(fixture, processInstanceId, step); + refused = await fixture.completeTask(taskId).then( + () => false, + () => true, + ); + } + const running = await isRunning(fixture, processInstanceId); + observations.push({ + tasks: running + ? activeTaskKeys(await fixture.getActiveTasks(processInstanceId)) + : [], + jobs: running + ? (await jobsOfInstance(fixture, processInstanceId)).length + : 0, + running, + ...(refused ? { refused } : {}), + }); + } + return observations; + } + + it.each<[string, string, Record, string[], Observation[]]>([ + [ + 'a task with no outgoing flow ends the instance like one followed by an end event', + 'implicit-end', + {}, + ['T'], + [{ tasks: [], jobs: 0, running: false }], + ], + [ + 'with asyncAfter, a task with no outgoing flow leaves the same single job, which ends the instance', + 'implicit-end-async', + {}, + ['T', 'resume'], + [ + { tasks: [], jobs: 1, running: true }, + { tasks: [], jobs: 0, running: false }, + ], + ], + [ + 'two flows entering a task start it once per token, like an exclusive merge', + 'implicit-merge', + {}, + ['A', 'B'], + [ + { tasks: ['B', 'C'], jobs: 0, running: true }, + { tasks: ['C', 'C'], jobs: 0, running: true }, + ], + ], + [ + 'two unconditioned flows leaving a task start both branches, like a parallel fork', + 'implicit-split', + {}, + ['T'], + [{ tasks: ['A', 'B'], jobs: 0, running: true }], + ], + [ + 'conditioned flows leaving a task start every branch whose condition holds, like an inclusive fork', + 'conditional-split', + { x: 3 }, + ['T'], + [{ tasks: ['A', 'B'], jobs: 0, running: true }], + ], + [ + "a task's default flow is taken only when no condition holds, like an inclusive fork's", + 'conditional-split', + { x: 0 }, + ['T'], + [{ tasks: ['D'], jobs: 0, running: true }], + ], + [ + 'with no condition holding and no default, completing the task is refused, as at an inclusive fork', + 'conditional-split-no-default', + { x: 0 }, + ['T'], + [{ tasks: ['T'], jobs: 0, running: true, refused: true }], + ], + [ + 'an unconditioned flow beside a conditioned one is always taken, as at an inclusive fork', + 'mixed-split', + { x: 0 }, + ['T'], + [{ tasks: ['B'], jobs: 0, running: true }], + ], + ])( + '%s', + async (_title, key, variables, steps, want) => { + expect(await run(key, variables, steps)).toEqual(want); + expect(await run(`${key}-x`, variables, steps)).toEqual(want); + }, + 60_000, + ); + + it.each< + [string, string, Record, string, Observation, Observation] + >([ + [ + 'with asyncAfter, a task makes one job per branch where a task before a parallel fork makes one', + 'split-async', + {}, + 'T', + { tasks: [], jobs: 2, running: true }, + { tasks: [], jobs: 1, running: true }, + ], + [ + "a repeated task naming a default fails to leave, where it leaves fine through an inclusive fork's default", + 'loop-default', + { x: 2 }, + 'T', + { tasks: ['T'], jobs: 0, running: true, refused: true }, + { tasks: ['A'], jobs: 0, running: true }, + ], + [ + 'an inclusive join goes on once a route is led away from it, where a parallel join waits for it forever', + 'diverted-join', + {}, + 'A', + { tasks: ['C'], jobs: 0, running: true }, + { tasks: [], jobs: 0, running: true }, + ], + ])( + '%s', + async (_title, key, variables, step, direct, written) => { + expect(await run(key, variables, [step])).toEqual([direct]); + expect(await run(`${key}-x`, variables, [step])).toEqual([written]); + }, + 60_000, + ); + + it('the comparison helper bridges exactly the pairs that ran the same', async () => { + const verdicts = await Promise.all( + PAIRS.map(async ({ key, direct, written }) => { + const [a, b] = await Promise.all( + [processXml(key, direct), processXml(`${key}-x`, written)].map( + async (xml) => (await xmlToIr(wrap(xml))).ir, + ), + ); + const report = compareModels({ ...a, id: b.id }, b); + return [ + key, + report.level === 'changed' + ? 'changed' + : report.normalizations.map((n) => n.names), + ]; + }), + ); + expect(verdicts).toEqual(PAIRS.map(({ key, verdict }) => [key, verdict])); + }); +}); + +const wrap = (processes: string): string => + ` + + +${processes} +`; diff --git a/tests/e2e/invoice-approval.test.ts b/tests/e2e/invoice-approval.test.ts index 8189294..4c6e4f1 100644 --- a/tests/e2e/invoice-approval.test.ts +++ b/tests/e2e/invoice-approval.test.ts @@ -106,7 +106,8 @@ describe.skipIf(SKIP)('E2E: invoice-approval on Spring Boot Operaton', () => { } expect(status).toBe(1); - expect(stderr).toContain("Service task 'BadService_1'"); - expect(stderr).toContain('unsupported service task form'); + expect(stderr.split('\n')[0]).toMatch( + /^Error: .*: service task 'BadService_1' uses an unsupported execution form: no execution discriminator$/, + ); }, 30_000); }); diff --git a/tests/e2e/repetition.test.ts b/tests/e2e/repetition.test.ts index 079a006..252cb27 100644 --- a/tests/e2e/repetition.test.ts +++ b/tests/e2e/repetition.test.ts @@ -1,4 +1,9 @@ import { describe, it, expect, beforeAll } from 'vitest'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { astToIr, irToXml } from '@bpmn-script/transform'; import type { ActiveTask, FixtureAdapter } from '../fixtures/index.js'; import { @@ -12,12 +17,33 @@ import { historicActivities, waitForTaskKeys, waitForTasks, + waitUntilFinished, } from '../helpers/engine-rest.js'; +import { parseToAst, validationErrors } from '../helpers/pipeline.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); const PROCESS_KEY = 'batch-approval'; const EMPTY_KEY = 'empty-batch'; +const DOCUMENTED_KEY = 'documented-repetition'; + +// The engine reads a loop count and a completion condition untrimmed, so the +// documentation child beside each must leave the value it documents intact. +const DOCUMENTED_SOURCE = `process ${DOCUMENTED_KEY} { + start Opened + user Approve for 3(assignee: "demo", countDocumentation: "One approval per board member.") + subprocess Lines { + input lines = ["ann", "bob", "cal"] + } { + user Review for each line in lines(assignee: "demo", eachDocumentation: "The line this run reviews.") + } + user Check for 3 sequentially until (nrOfCompletedInstances >= 2, conditionDocumentation: "Two checks are enough.")(assignee: "demo") + end Closed +} +`; + describe.skipIf(SKIP)('E2E: repetition on Spring Boot Operaton', () => { let fixture: FixtureAdapter; @@ -160,6 +186,40 @@ describe.skipIf(SKIP)('E2E: repetition on Spring Boot Operaton', () => { ).toEqual(['CloseBatch']); }, 60_000); + it('runs a documented count, element variable and completion condition as written', async () => { + expect(await validationErrors(DOCUMENTED_SOURCE)).toEqual([]); + const xmlPath = resolve(__dirname, `../../out/${DOCUMENTED_KEY}.bpmn`); + mkdirSync(dirname(xmlPath), { recursive: true }); + const xml = await irToXml(astToIr(await parseToAst(DOCUMENTED_SOURCE))); + expect(xml.match(//g)).toHaveLength(3); + writeFileSync(xmlPath, xml); + await fixture.deploy(xmlPath, `${DOCUMENTED_KEY}-test`); + const { processInstanceId } = await fixture.startProcess( + DOCUMENTED_KEY, + {}, + ); + + expect( + await waitForTaskKeys( + fixture, + processInstanceId, + (keys) => keys.length >= 3, + ), + ).toEqual(['Approve', 'Approve', 'Approve']); + await completeAll(processInstanceId, 'Approve', 3); + + const reviews = await tasksOf(processInstanceId, 'Review', 3); + expect( + (await Promise.all(reviews.map((task) => boundElement(task.id)))).sort(), + ).toEqual(['ann', 'bob', 'cal']); + for (const task of reviews) { + await fixture.completeTask(task.id); + } + + await completeInTurn(processInstanceId, 'Check', 2); + expect(await waitUntilFinished(fixture, processInstanceId)).toBe(true); + }, 60_000); + it('leaves a repetition of no runs without running it at all', async () => { const { processInstanceId } = await fixture.startProcess(EMPTY_KEY, {}); diff --git a/tests/else-fail.round-trip.test.ts b/tests/else-fail.round-trip.test.ts new file mode 100644 index 0000000..129833c --- /dev/null +++ b/tests/else-fail.round-trip.test.ts @@ -0,0 +1,126 @@ +// A split whose every route is conditioned and which names no fallback stops +// the run when no condition holds. `else fail` is its form in the script, so +// both directions keep that behaviour and neither reports a change. + +import { describe, expect, it } from 'vitest'; + +import { astToIr, irToDsl, xmlToIr } from '@bpmn-script/transform'; + +import { bpmnDoc } from './helpers/bpmn-doc.js'; +import { compareModels } from './helpers/model-equivalence.js'; +import { parseToAst, roundTrip, validationErrors } from './helpers/pipeline.js'; + +const flow = (id: string, from: string, to: string, condition?: string) => + condition === undefined + ? ` ` + : ` ` + + `${condition}`; + +const twoConditionedRoutes = (kind: 'exclusiveGateway' | 'inclusiveGateway') => + bpmnDoc( + [ + ' ', + ` `, + ' ', + ' ', + ` `, + ' ', + flow('F0', 'S', 'Split'), + flow('F1', 'Split', 'Small', '${amount <= 1000}'), + flow('F2', 'Split', 'Large', '${amount > 5000}'), + flow('F3', 'Small', 'Merge'), + flow('F4', 'Large', 'Merge'), + flow('F5', 'Merge', 'E'), + ].join('\n'), + ); + +describe("an imported split with no fallback prints as 'else fail'", () => { + it.each([ + [ + 'an exclusive gateway', + twoConditionedRoutes('exclusiveGateway'), + [ + ' if (amount <= 1000) {', + ' user Small', + ' } else if (amount > 5000) {', + ' user Large', + ' } else fail', + ], + ], + [ + 'an inclusive gateway', + twoConditionedRoutes('inclusiveGateway'), + [ + ' parallel {', + ' if (amount <= 1000) {', + ' user Small', + ' }', + ' if (amount > 5000) {', + ' user Large', + ' }', + ' else fail', + ' }', + ], + ], + ])( + '%s whose every route is conditioned compares with no differences and no warning', + async (_title, xml, split) => { + const { ir, warnings } = await xmlToIr(xml); + const printed = irToDsl(ir); + const rebuilt = astToIr(await parseToAst(printed.source)); + const { level, differences } = compareModels(ir, rebuilt); + + expect({ + warnings: [...warnings, ...printed.warnings], + source: printed.source, + errors: await validationErrors(printed.source), + comparison: { level, differences }, + }).toEqual({ + warnings: [], + source: [ + 'process p {', + ' var amount: any', + ' start S', + ...split, + ' end E', + '}', + '', + ].join('\n'), + errors: [], + comparison: { level: 'same', differences: [] }, + }); + }, + ); +}); + +describe("'else fail' compiles to a split with no default and prints back unchanged", () => { + it.each([ + [ + 'an if chain', + 'if (amount <= 1000) {\n user Small\n } else if (amount > 5000) {\n user Large\n } else fail', + 'exclusiveGateway', + ], + [ + 'a parallel block', + 'parallel {\n if (amount <= 1000) {\n user Small\n }\n if (amount > 5000) {\n user Large\n }\n else fail\n }', + 'inclusiveGateway', + ], + ])('%s', async (_title, split, kind) => { + const source = `process p {\n var amount: number\n start S\n ${split}\n end E\n}\n`; + const run = await roundTrip(source); + const gateway = run.ir1.flowElements.find((fe) => fe.kind === kind)!; + + expect({ + default: 'defaultFlowId' in gateway ? gateway.defaultFlowId : undefined, + routesOut: run.ir1.sequenceFlows.filter((f) => f.sourceRef === gateway.id) + .length, + warnings: run.warnings, + dsl: run.dsl, + }).toEqual({ + default: undefined, + routesOut: 2, + warnings: [], + dsl: source.replace('number', 'any'), + }); + }); +}); diff --git a/tests/engine-accepted.round-trip.test.ts b/tests/engine-accepted.round-trip.test.ts new file mode 100644 index 0000000..dcc58f4 --- /dev/null +++ b/tests/engine-accepted.round-trip.test.ts @@ -0,0 +1,643 @@ +// Shapes the engine deploys and runs whose print the validator once refused, +// each cut down from a model in Operaton's own test suite. + +import { it, expect } from 'vitest'; + +import { irToDsl, xmlToIr } from '@bpmn-script/transform'; + +import { bpmnDoc } from './helpers/bpmn-doc.js'; +import { compareModels } from './helpers/model-equivalence.js'; +import type { ModelReport } from './helpers/model-equivalence.js'; +import { printDsl, roundTrip, validationErrors } from './helpers/pipeline.js'; + +/** `T` between a start `S` and an end `E`, under the flow ids a compile mints. */ +const between = (step: string): string => + [ + '', + step, + '', + '', + '', + ].join('\n'); + +const printedBetween = (step: string): string => + `process p {\n start S\n ${step}\n end E\n}\n`; + +// The fourth column is what the rebuild compares as against the import, with +// the normalizations that explain any difference ('same' if absent). +const flows = (pairs: string): string => + pairs + .split(' ') + .map((pair) => pair.split('>')) + .map( + ([from, to]) => + ``, + ) + .join('\n'); + +const conditioned = (from: string, to: string, body: string): string => + `${body}`; + +const STEP_FORK = 'Gateway_parallelGateway_[in:T]_[out:A,B]'; + +const ROWS: [ + title: string, + body: string, + printed: string, + comparison?: Omit, +][] = [ + [ + "a service task whose 'expression' is constant text prints it quoted", + between(''), + printedBetween('service T(expression: "{true}")'), + ], + [ + 'a script task with an empty body prints an empty fence', + between( + '', + ), + printedBetween('script T ```groovy\n```'), + ], + [ + 'a repetition count computed in JUEL prints as arithmetic', + between( + '' + + '${5-2}' + + '', + ), + printedBetween('user T for 5 - 2'), + { + level: 'canonical', + normalizations: [ + { names: ['expression-parse-trees'], elements: ['p/T'] }, + ], + }, + ], + [ + 'a repetition count written as text around a template prints quoted', + between( + '' + + '1${x}' + + '', + ), + printedBetween('user T for "1${x}"'), + ], + [ + 'a decision version written as text around a template prints quoted', + between( + '', + ), + printedBetween('decide T(decision: "d", version: "0${v}")'), + ], + [ + 'a form field property keyed by a keyword prints the key in backticks', + between( + '' + + '' + + '' + + '' + + '', + ), + printedBetween( + 'user T {\n form {\n f: string {\n property `true` = "yes"\n }\n }\n }', + ), + ], + [ + 'an injected field named by a keyword prints the name in backticks', + between( + '' + + '' + + '', + ), + printedBetween('service T(class: "a.B") {\n field `true` = "x"\n }'), + ], + [ + 'two routes from a fork into one end event print as jumps to it after the block', + [ + '', + '', + '', + '', + '', + flows('S>F F>A F>B A>E B>E'), + ].join('\n'), + `process p { + start S + parallel { + { + user A + goto E + } + { + user B + goto E + } + } + end E +} +`, + { + level: 'same', + normalizations: [ + { + names: ['synthesized-ids'], + elements: [ + 'p/Flow_F_A', + 'p/Flow_F_B', + 'p/Flow_Gateway_p_1_fork_A', + 'p/Flow_Gateway_p_1_fork_B', + ], + }, + { names: ['element-order'], elements: ['p'] }, + ], + }, + ], + [ + 'two routes from a step into one end event print as jumps to it after the block', + [ + '', + ...['T', 'A', 'B'].map((id) => ``), + '', + flows('S>T T>A T>B A>E B>E'), + ].join('\n'), + `process p { + start S + user T + parallel { + { + user A + goto E + } + { + user B + goto E + } + } + end E +} +`, + { + level: 'same', + normalizations: [ + { + names: ['synthesized-ids'], + elements: [ + 'p/Flow_Gateway_p_2_fork_A', + 'p/Flow_Gateway_p_2_fork_B', + 'p/Flow_T_A', + 'p/Flow_T_B', + ], + }, + { names: ['element-order'], elements: ['p'] }, + { + names: ['uncontrolled-flow'], + elements: [ + `p/Flow_${STEP_FORK}_A`, + `p/Flow_${STEP_FORK}_B`, + 'p/Flow_T_A', + 'p/Flow_T_B', + `p/Flow_T_${STEP_FORK}`, + `p/${STEP_FORK}`, + ], + }, + ], + }, + ], +]; + +it.each(ROWS)('%s', async (_title, body, printed, comparison) => { + const { ir } = await xmlToIr(bpmnDoc(body)); + const dsl = printDsl(ir); + expect(dsl).toBe(printed); + expect(await validationErrors(dsl)).toEqual([]); + const { ir2: rebuilt } = await roundTrip(dsl); + expect(compareModels(ir, rebuilt)).toEqual({ + level: 'same', + normalizations: [], + ...comparison, + differences: [], + }); +}); + +/** `A`/`B` and `C`/`D` each meet at a join of their own before `J3`. */ +const nestedJoins = (kind: string): string => + [ + '', + ``, + ...['A', 'B', 'C', 'D', 'T'].map((id) => ``), + ...['J1', 'J2', 'J3'].map((id) => ``), + '', + flows('S>F F>A F>B F>C F>D A>J1 B>J1 C>J2 D>J2 J1>J3 J2>J3 J3>T T>E'), + ].join('\n'); + +// A gateway's key in a difference, by the routes in and out of it. +const parallel = (ins: string, outs: string): string => + `Gateway_parallelGateway_[in:${ins}]_[out:${outs}]`; +const inclusive = (ins: string, outs: string): string => + `Gateway_inclusiveGateway_[in:${ins}]_[out:${outs}]`; +const INC = 'gateway:inclusiveGateway'; +const IN_AB = inclusive('A,B', INC); +const IN_CD = inclusive('C,D', INC); +const IN_OUTER = inclusive(`${INC},${INC}`, 'T'); +const PAR_AB = parallel('A,B', 'T'); +const AB_TO_END = parallel('A,B', 'endEvent:'); +const INC_TO_E = inclusive('A,B', 'E'); +const PAR_TO_E = parallel('A,B', 'E'); + +// Shapes the script cannot hold, printed valid with a warning naming each +// element whose loss the remaining differences show. A difference is the side +// holding the element ('a' the import, 'b' the rebuild) and its key. +it.each< + [ + title: string, + body: string, + printed: string, + warnings: [category: string, elementId: string][], + differences: string[], + ] +>([ + [ + 'a step nothing leads to is left out', + between('\n'), + printedBetween('user T'), + [['unreachable', 'O']], + ['a O'], + ], + [ + 'routes out of a sub-process that stop without an end event are joined', + [ + '', + '', + '', + '', + '', + flows('S>P P>A P>B'), + ].join('\n'), + `process p { + start S + subprocess P { + start PS + end PE + } + parallel { + { + user A + } + { + user B + } + } +} +`, + [['addedJoin', 'P']], + [ + 'b endEvent:', + `b ${AB_TO_END}`, + `b Flow_A_${AB_TO_END}`, + `b Flow_B_${AB_TO_END}`, + `b Flow_${AB_TO_END}_endEvent:`, + ], + ], + [ + 'one route that stops without an end event beside one that ends gains an end event and no join', + [ + '', + '', + '', + '', + '', + flows('S>F F>A F>B B>E'), + ].join('\n'), + `process p { + start S + parallel { + { + user A + } + { + user B + end E + } + } +} +`, + [], + [], + ], + [ + 'conditioned routes from an inclusive fork into one end event print as jumps to it after the block', + [ + '', + '', + '', + '', + '', + flows('S>F A>E B>E'), + conditioned('F', 'A', '${x == 1}'), + conditioned('F', 'B', '${x <= 2}'), + ].join('\n'), + `process p { + var x: any + start S + parallel { + if (x == 1) { + user A + goto E + } + if (x <= 2) { + user B + goto E + } + else fail + } + end E +} +`, + [], + [], + ], + [ + 'an inclusive fork and join weighing no branch print as a parallel block, warned at the join', + [ + '', + '', + '', + '', + '', + '', + flows('S>F F>A F>B A>J B>J J>E'), + ].join('\n'), + `process p { + start S + parallel { + { + user A + } + { + user B + } + } + end E +} +`, + [['changedJoin', 'J']], + [INC_TO_E, PAR_TO_E].flatMap((g, i) => + [g, `Flow_A_${g}`, `Flow_B_${g}`, `Flow_${g}_E`].map( + (key) => `${'ab'[i]} ${key}`, + ), + ), + ], + [ + 'joins nested under one fork print as nested parallel blocks', + nestedJoins('parallelGateway'), + `process p { + start S + parallel { + { + parallel { + { + user A + } + { + user B + } + } + } + { + parallel { + { + user C + } + { + user D + } + } + } + } + user T + end E +} +`, + [['nestedFork', 'F']], + [], + ], + [ + 'a join inside a join nests one block deeper', + [ + '', + '', + ...['A1', 'A2', 'B', 'C', 'D'].map( + (id) => ``, + ), + ...['J0', 'J1', 'J2', 'J3'].map( + (id) => ``, + ), + '', + flows( + 'S>F F>A1 F>A2 F>B F>C F>D A1>J0 A2>J0 J0>J1 B>J1 C>J2 D>J2 J1>J3 J2>J3 J3>E', + ), + ].join('\n'), + `process p { + start S + parallel { + { + parallel { + { + parallel { + { + user A1 + } + { + user A2 + } + } + } + { + user B + } + } + } + { + parallel { + { + user C + } + { + user D + } + } + } + } + end E +} +`, + [['nestedFork', 'F']], + [], + ], + [ + 'inclusive joins nested under one fork leave as jumps past them', + nestedJoins('inclusiveGateway'), + `process p { + start S + parallel { + { + user A + } + { + user B + } + { + user C + goto T + } + { + user D + goto T + } + } + user T + end E +} +`, + [ + ['changedJoin', 'J1'], + ['skippedJoin', 'J2'], + ['skippedJoin', 'J3'], + ], + [ + ...[IN_AB, IN_CD, IN_OUTER].map((g) => `a ${g}`), + ...['A', 'B'].map((id) => `a Flow_${id}_${IN_AB}`), + ...['C', 'D'].map((id) => `a Flow_${id}_${IN_CD}`), + `a Flow_${IN_AB}_${IN_OUTER}`, + `a Flow_${IN_CD}_${IN_OUTER}`, + `a Flow_${IN_OUTER}_T`, + `b ${PAR_AB}`, + `b Flow_A_${PAR_AB}`, + `b Flow_B_${PAR_AB}`, + 'b Flow_C_T', + 'b Flow_D_T', + `b Flow_${PAR_AB}_T`, + ], + ], +])('%s', async (_title, body, source, warnings, differences) => { + const { ir } = await xmlToIr(bpmnDoc(body)); + const printed = irToDsl(ir); + expect(printed.source).toBe(source); + expect(printed.warnings.map((w) => [w.category, w.elementId])).toEqual( + warnings, + ); + expect(await validationErrors(printed.source)).toEqual([]); + const { ir2: rebuilt } = await roundTrip(printed.source); + const report = compareModels(ir, rebuilt); + expect(report.differences.map((d) => `${d.only} ${d.element}`)).toEqual( + differences, + ); +}); + +// Prints the rebuild holds differently but Operaton runs the same, which +// compare equal only through the normalization each row names. +it.each<[title: string, body: string, normalizations: string[][]]>([ + [ + 'an unconditioned last route of an exclusive gateway comes back as its default through unconditioned-last-route', + [ + '', + '', + ...['A', 'B'].map((id) => ``), + ...['EA', 'EB'].map((id) => ``), + flows('S>G'), + conditioned('G', 'A', '${x > 1}'), + flows('G>B A>EA B>EB'), + ].join('\n'), + [ + ['synthesized-ids'], + ['pass-through-gateways'], + ['element-order'], + ['unconditioned-last-route'], + ], + ], + [ + 'joins nested under one fork come back with a fork ahead of each join through nested-forks', + nestedJoins('parallelGateway'), + [['synthesized-ids'], ['element-order'], ['nested-forks']], + ], +])('%s', async (_title, body, normalizations) => { + const { ir } = await xmlToIr(bpmnDoc(body)); + const { ir2: rebuilt } = await roundTrip(printDsl(ir)); + const report = compareModels(ir, rebuilt); + expect({ + level: report.level, + normalizations: report.normalizations.map((n) => n.names), + differences: report.differences, + }).toEqual({ level: 'same', normalizations, differences: [] }); +}); + +// The e2e/implicit-routing suite runs the waiting parallel join on Operaton. +it('nested inclusive joins name the join printed as parallel and the step a jump past one runs once per route for', async () => { + const { ir } = await xmlToIr(bpmnDoc(nestedJoins('inclusiveGateway'))); + expect(irToDsl(ir).warnings.map((w) => w.message)).toEqual([ + [ + "inclusive gateway 'J1' was printed as the join of a 'parallel' block", + 'note: no branch of the block carries a condition, so the script reads the block back as parallel gateways', + "note: a parallel join waits for a route from every branch, where 'J1' goes on once no other route can still reach it", + 'note: so a route led away from its branch before the join, by an interrupting boundary event for one, leaves the rebuilt process waiting at the join forever', + ].join('\n'), + ...['J2', 'J3'].map((join) => + [ + `a jump was printed past gateway '${join}' and its join was lost`, + "note: the route into it has no form in the script, so it leaves as a jump to 'T'", + `note: a route taking the jump goes on to 'T' at once instead of waiting at '${join}' for the others`, + "note: so 'T' and what follows run once for each such route instead of once after the join", + ].join('\n'), + ), + ]); +}); + +// Operaton fails every run of `T` with ENGINE-02005, and leaves the same +// routes through an inclusive fork's default (both in e2e/implicit-routing). +it("a repeated step's default prints as the fallback of a fork after it and is warned on import", async () => { + const { ir, warnings } = await xmlToIr( + bpmnDoc( + [ + '', + '' + + '2' + + '', + ...['A', 'B', 'D'].map((id) => ``), + ...['A', 'B', 'D'].map((id) => ``), + flows('S>T'), + conditioned('T', 'A', '${x > 1}'), + conditioned('T', 'B', '${x > 2}'), + flows('T>D A>EA B>EB D>ED'), + ].join('\n'), + ), + ); + expect(warnings.map((w) => [w.category, w.elementId])).toEqual([ + ['behaviourChanged', 'T'], + ]); + const printed = irToDsl(ir); + expect(printed.source).toBe(`process p { + var x: any + start S + user T for 2 + parallel { + if (x > 1) { + user A + end EA + } + if (x > 2) { + user B + end EB + } + else { + user D + end ED + } + } +} +`); + expect(printed.warnings).toEqual([]); + expect(await validationErrors(printed.source)).toEqual([]); + const { ir2: rebuilt } = await roundTrip(printed.source); + expect(compareModels(ir, rebuilt).level).toBe('changed'); +}); diff --git a/tests/engine-attributes.round-trip.test.ts b/tests/engine-attributes.round-trip.test.ts index bbf40ec..95c4f24 100644 --- a/tests/engine-attributes.round-trip.test.ts +++ b/tests/engine-attributes.round-trip.test.ts @@ -13,7 +13,7 @@ const rt = roundTripFixture('engine-attributes', { recompile: 'clean', }); -// Not the exporter's `P30D` default, which the importer reads as unwritten. +// Not the exporter's `P30D` default, which the printer leaves out of the script. const PROCESS_HEADER = { versionTag: '3.1.0', historyTimeToLive: 'P90D', diff --git a/tests/event-handlers.round-trip.test.ts b/tests/event-handlers.round-trip.test.ts index 113f8e8..c0135b4 100644 --- a/tests/event-handlers.round-trip.test.ts +++ b/tests/event-handlers.round-trip.test.ts @@ -119,9 +119,21 @@ describeImportFirst( IMPORT_FIRST_BPMN, (first) => { it('normalizes the camunda: error message and binding aliases into the DSL', () => { - expect(first.dsl).toContain('error BOOM(message: "It went boom")'); - expect(first.dsl).toContain( - 'on error(BOOM, code: code, message: text) {', + expect(first.dsl).toBe( + [ + 'process import-first {', + ' error BOOM(message: "It went boom")', + ' start Begin', + ' service DoWork(label: "Perform the work", class: "com.example.WorkDelegate")', + ' end Finish', + ' on error(BOOM, code: code, message: text) {', + ' start CaughtBoom', + ' service Cleanup(label: "Clean things up", class: "com.example.CleanupDelegate")', + ' end Recovered', + ' }', + '}', + '', + ].join('\n'), ); }); }, diff --git a/tests/event-positions.round-trip.test.ts b/tests/event-positions.round-trip.test.ts index b6ac61d..31777e0 100644 --- a/tests/event-positions.round-trip.test.ts +++ b/tests/event-positions.round-trip.test.ts @@ -22,14 +22,27 @@ it('keeps the message start and the labelled terminate end at every hop, and wri 'Abandon every path', ]); } - 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); - } + expect(rt.dslPrime).toBe( + [ + 'process order-dispatch {', + ' start OrderReceived message("OrderReceived", label: "An order arrives") {', + ' form {', + ' amount: number "Order amount"', + ' }', + ' }', + ' user ConfirmOrder(label: "Confirm the order", assignee: "demo")', + ' emit message NotifyWarehouse("WarehouseNotified", class: "com.example.orders.NotifyWarehouseDelegate")', + ' if (amount > 1000) {', + ' service EscalateOrder(label: "Escalate the order", class: "com.example.orders.EscalateDelegate")', + ' end OrderAbandoned terminate(label: "Abandon every path")', + ' } else {', + ' service DispatchOrder(label: "Dispatch the order", class: "com.example.orders.DispatchDelegate")', + ' }', + ' throw message OrderAcknowledged("OrderAcknowledged", class: "com.example.orders.AcknowledgeOrderDelegate")', + '}', + '', + ].join('\n'), + ); }); it('carries one bpmn:Message per name in first-appearance order, the start referencing its root beside its form data', () => { diff --git a/tests/event-triggers.round-trip.test.ts b/tests/event-triggers.round-trip.test.ts index e49fd10..f78934a 100644 --- a/tests/event-triggers.round-trip.test.ts +++ b/tests/event-triggers.round-trip.test.ts @@ -130,10 +130,28 @@ describeImportFirst( IMPORT_FIRST_BPMN, (first) => { 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) {'); + expect(first.dsl).toBe( + [ + 'process parcel-tracking {', + ' var stockLevel: any', + ' start Begin', + ' service Dispatch(label: "Send the parcel out", class: "com.example.DispatchDelegate")', + ' emit signal Broadcast("ParcelDispatched")', + ' throw signal Done("ParcelDispatched")', + ' on condition(stockLevel < 5) {', + ' start LowStock', + ' service Reorder(label: "Reorder the item", class: "com.example.ReorderDelegate")', + ' end Reordered', + ' }', + ' on timer(at: "2026-09-01T08:00:00") {', + ' start Deadline', + ' service Chase(label: "Chase the courier", class: "com.example.ChaseDelegate")', + ' end Chased', + ' }', + '}', + '', + ].join('\n'), + ); 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 9502964..0acc9be 100644 --- a/tests/examples.health.test.ts +++ b/tests/examples.health.test.ts @@ -10,6 +10,7 @@ import { astToIr, irToDsl, irToXml, xmlToIr } from '@bpmn-script/transform'; import { parseToAst, validate } from './helpers/pipeline.js'; import { normalizeIr } from './helpers/normalize-ir.js'; +import { allOf } from './helpers/ir-query.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -64,20 +65,17 @@ describe('every deployable example', () => { }); describe('construct shapes pinned only by a deployable example', () => { - it('awaiting-confirmation desugars the await into an intermediateCatchEvent carrying the message definition', async () => { + it('awaiting-confirmation desugars its one await into an intermediateCatchEvent carrying the message definition', async () => { const ir = astToIr(await parseToAst(sourceOf('awaiting-confirmation'))); expect( - ir.flowElements.find((el) => el.kind === 'intermediateCatchEvent'), - ).toMatchObject({ - kind: 'intermediateCatchEvent', - eventDefinition: { kind: 'message', messageName: 'ConfirmationReceived' }, - }); + allOf(ir, 'intermediateCatchEvent').map((el) => el.eventDefinition), + ).toEqual([{ kind: 'message', messageName: 'ConfirmationReceived' }]); }); it.each([ [ 'order-handling', - 'the attached boundary events', + 'every handler as a boundary event on its host', [ '', '', @@ -88,22 +86,26 @@ describe('construct shapes pinned only by a deployable example', () => { ], [ 'charge-with-recovery', - 'an interrupting error boundary on the charge service task', + 'one 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) => { + ])('%s compiles %s', async (file, _what, boundaries) => { const xml = await irToXml(astToIr(await parseToAst(sourceOf(file)))); - for (const fragment of fragments) expect(xml).toMatch(fragment); + expect(xml.match(/]*>/g)).toEqual(boundaries); + }); + + it('compensating-saga compiles the undo handler wired over the compensable subprocess', async () => { + const xml = await irToXml( + astToIr(await parseToAst(sourceOf('compensating-saga'))), + ); + for (const fragment of [ + '', + /[\s\S]*?/, + '', + ]) { + expect(xml).toMatch(fragment); + } }); }); diff --git a/tests/fuzz-regressions.test.ts b/tests/fuzz-regressions.test.ts index ab13e75..607a15d 100644 --- a/tests/fuzz-regressions.test.ts +++ b/tests/fuzz-regressions.test.ts @@ -2,12 +2,23 @@ // while the generator still draws it. import { it, expect } from 'vitest'; +import { Diagnostic } from 'vscode-languageserver-types'; import { xmlToIr, irToDsl } from '@bpmn-script/transform'; -import type { BpmnProcess, FlowContainer } from '@bpmn-script/transform'; +import type { + BpmnProcess, + FlowContainer, + PrintWarning, +} from '@bpmn-script/transform'; import { bpmnDoc } from './helpers/bpmn-doc.js'; -import { printDsl, roundTrip, validationErrors } from './helpers/pipeline.js'; +import { + printDsl, + roundTrip, + validate, + validationErrors, +} from './helpers/pipeline.js'; +import { generateProgram, renderProgram } from './helpers/fuzz-generator.js'; import { compareModels, modelSignature } from './helpers/model-equivalence.js'; import type { ModelComparison } from './helpers/model-equivalence.js'; @@ -1079,6 +1090,52 @@ const ORDER_ROWS: [ user Z }`, ], + [ + "a chain only a boundary handler's branch jumps to prints from its head, not from its end", + `process invoice_batch { + escalation NEEDS_REVIEW + + service Notify(type: "shell") { + field command = "df" + } + end Done4 + start Start5 signal("Ready") + end Done9 + on Notify: error { + if (true) { + } else if (true) { + call Call15(process: "payment-run") + emit escalation(NEEDS_REVIEW) + end Done18 + } + } +}`, + 'canonical', + ], + [ + 'a step an await branch shares with a sibling if branch prints in the if branch, not the await branch', + `process order-fulfilment { + if (true) { + parallel { + { + } + { + } + } + receive Record + } else if (true) { + await { + timer(every: "R3/PT10M") { + goto Record + } + message("Quote Received") { + end Done + } + } + } +}`, + 'canonical', + ], ]; const ROWS: [title: string, source: string, comparison?: ModelComparison][] = [ @@ -1919,22 +1976,6 @@ const ROWS: [title: string, source: string, comparison?: ModelComparison][] = [ 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 }`, ], [ @@ -2088,34 +2129,6 @@ const ROWS: [title: string, source: string, comparison?: ModelComparison][] = [ 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") }`, ], [ @@ -2346,7 +2359,7 @@ it.each(ORDER_ROWS)( const dsl = printDsl(model); expect(await validationErrors(dsl)).toEqual([]); const { ir1: reread, dsl: next } = await roundTrip(dsl); - expect(compareModels(model, reread)).toBe(comparison); + expect(compareModels(model, reread).level).toBe(comparison); expect(next).toBe(dsl); }, ); @@ -2720,22 +2733,26 @@ const PRINTS_AS_WRITTEN: [ } `, ], -]; - -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 { + // A link catch printed right behind its throw keeps the flow running, so a + // route over the hop reaches the merge like any other. + [ + 'an if branch that hops over a link pair beside an ending branch keeps the step after the if', + `process p { + var c: any + if (c) { + emit link("A") + await link("A") + } else { + end E + } + user After +} +`, + plain, + ], + [ + 'an if whose only live branch crosses a link pair keeps the step after its inner if in that branch', + `process p { var c: any var x: any if (c) { @@ -2755,15 +2772,61 @@ it('an if whose only live branch crosses a link pair sinks the step after it int } else { end Done } -}`; - const { dsl } = await roundTrip(source); - expect(await validationErrors(dsl)).toEqual([]); - const second = await roundTrip(dsl); - expect(second.dsl).toBe(dsl); -}); +} +`, + plain, + ], + [ + 'a do-while whose body runs over link pairs to its test prints as a do-while', + `process p { + var c: any + do { + if (c) { + emit link("A") + await link("A") + } else { + emit link("B") + await link("B") + } + user After + } while (c) +} +`, + plain, + ], + [ + 'a start after an if whose branches hop over link pairs closes the flow into its step with a goto', + `process claim-review { + var order: any + if (true) { + emit link("Retry") + await link("Retry") + } else { + emit link("Rework") + await link("Rework") + } + goto Review20 + start Start19 condition(order.paid) + service Review20(class: "com.example.Delegate") +} +`, + plain, + ], +]; -// 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.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 print leaves the unreachable `goto X` out, so it cannot 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 @@ -2782,7 +2845,7 @@ it('an unreachable predecessor of a branch entry does not push its chain out of } `; const { dsl } = await roundTrip(source); - expect(dsl).toBe(source); + expect(dsl).toBe(source.replace(' user Z\n goto X\n', '')); }); // J joins two of F's routes and forks again; the script has no mixed gateway, @@ -2819,10 +2882,19 @@ const MIXED_JOIN = bpmnDoc( ].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 { +const conditionalStart = (id: string, variable: string): string => + `` + + '' + + `\${${variable}}` + + ''; + +// Imported models whose print is pinned literally, valid, stable, and reread +// as the same model. +it.each<[title: string, xml: string, printed: string]>([ + [ + 'a gateway that merges a fork with an ending branch and splits again prints as the parallel join followed by a parallel', + MIXED_JOIN, + `process p { start S parallel { { @@ -2847,44 +2919,38 @@ it('a gateway that merges a fork with an ending branch and splits again prints a } } } -`); - 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 { +`, + ], + [ + // A body opening with `start B` would have no implicit start, so the + // print jumps to T first. + 'an implicit start and a conditional start entering the same step keep both starts and the implicit end', + bpmnDoc( + [ + '', + conditionalStart('B', 'c'), + '', + '', + '', + '', + '', + ].join('\n'), + ), + `process p { var c: any goto T start B condition(c) user T } -`); +`, + ], +])('%s', async (_title, xml, printed) => { + const { ir } = await xmlToIr(xml); + const dsl = printDsl(ir); + expect(dsl).toBe(printed); expect(await validationErrors(dsl)).toEqual([]); const { ir1: reread, dsl: next } = await roundTrip(dsl); - expect(compareModels(ir, reread)).toBe('same'); + expect(compareModels(ir, reread).level).toBe('same'); expect(next).toBe(dsl); }); @@ -2928,7 +2994,7 @@ it('a start entering a step reached only through a printed link hop keeps the fl `); expect(await validationErrors(dsl)).toEqual([]); const { ir1: reread, dsl: dsl2 } = await roundTrip(dsl); - expect(compareModels(ir, reread)).toBe('same'); + expect(compareModels(ir, reread).level).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([]); @@ -2945,50 +3011,115 @@ it('a start entering a step reached only through a printed link hop keeps the fl } `); const { ir1: rereadAgain, dsl: dsl3 } = await roundTrip(dsl2); - expect(compareModels(ir, rereadAgain)).toBe('same'); + expect(compareModels(ir, rereadAgain).level).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 { +// The elided end prints before the start, since a `goto` to it would name a +// target not yet printed, and the leftover edge degrades to the marker. +const elidedEndWarnings = (end: string): PrintWarning[] => [ + { + elementId: end, + category: 'droppedEdge', + message: + `a route into '${end}' was left out of the script\n` + + 'note: the route has no form in the script, so this part of the script is unstructured\n' + + `note: the marker comment where it belonged names '${end}' and is where hand-repair starts`, + }, + { + elementId: end, + category: 'refusedStatement', + message: + `step '${end}' has a name the script keeps for the names it derives itself\n` + + 'note: the printed script draws an error there when it is read back\n' + + 'help: rename the step in the model and print it again', + }, +]; + +it.each< + [ + title: string, + model: () => Promise, + end: string, + printed: string, + ] +>([ + [ + 'a conditional start entering the elided end keeps the end printed before the start', + async () => + ( + await xmlToIr( + bpmnDoc( + [ + '', + conditionalStart('B', 'c'), + '', + '', + '', + '', + '', + ].join('\n'), + ), + ) + ).ir, + 'EndEvent_p', + `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.', - }, - ]); +`, + ], + [ + // The source is refused, since the if's flow runs on through the link + // catch into the start; only its model is printed. + 'a start after an if whose branch hops over a link pair, both entering the elided end, closes the flow with that end', + () => + plain(`process onboarding { + var order: any + if (true) { + emit link("Rework") + await link("Rework") + } else { + throw signal("Ready") + } + start Start18 condition(order.paid) +}`), + 'EndEvent_onboarding', + `process onboarding { + var order: any + if (true) { + emit link("Rework") + await link("Rework") + } else { + throw signal("Ready") + } + end EndEvent_onboarding + start Start18 condition(order.paid) + // unstructured region: hand-repair required (dropped edge into EndEvent_onboarding) +} +`, + ], +])('%s', async (_title, model, end, expected) => { + const printed = irToDsl(await model()); + expect(printed.source).toBe(expected); + expect(printed.warnings).toEqual(elidedEndWarnings(end)); }); + +// These seeds once drew a cancel handler on an attempt with no cancel end, +// which the importer warns about and the fuzz run refuses. +it.each([2572, 3567, 7098, 7694, 9501])( + 'generated program %i hosts no cancel handler on an attempt nothing gives up', + async (seed) => { + const { diagnostics } = await validate( + renderProgram(generateProgram(seed)), + ); + expect( + diagnostics + .map((d) => Diagnostic.getMessageString(d)) + .filter((m) => m.startsWith('Nothing inside')), + ).toEqual([]); + }, +); diff --git a/tests/fuzz.test.ts b/tests/fuzz.test.ts index fabc33c..27a4e20 100644 --- a/tests/fuzz.test.ts +++ b/tests/fuzz.test.ts @@ -31,10 +31,10 @@ 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 ElementDifference, type ModelComparison, } from './helpers/model-equivalence.js'; import { @@ -169,7 +169,8 @@ interface Outcome { routesShuffled: boolean; dsl1?: string; diff?: string[]; - diffEntries?: DiffEntry[]; + diffEntries?: ElementDifference[]; + normalizations?: string[][]; ms: number; } @@ -193,68 +194,13 @@ async function bounded(stage: string, fn: () => Promise): Promise { } } -// 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)}`; +// One line per differing field, or per element present on one side only. +function diffLines(d: ElementDifference): string[] { + const key = `${d.container}/${d.element}`; + if (d.only !== undefined) return [`${key}: only in ${d.only}`]; + return d.fields.map( + (f) => `${key}.${f.path}: ${short(f.a)} -> ${short(f.b)}`, + ); } function short(x: unknown): string { @@ -271,7 +217,7 @@ const PROCESS_NAME_PATTERN = new RegExp(PROCESS_NAMES.join('|'), 'g'); * 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: /; + /^block '[^']+' has an end event that gives it up, but no cancel boundary event\s/; function normalizeMessage(m: string): string { return m @@ -281,14 +227,22 @@ function normalizeMessage(m: string): string { .slice(0, 200); } -function diffShape(diff: DiffEntry[]): string { +function diffShape(diff: ElementDifference[]): string { const leaf = (path: string): string => { - const segs = path.replace(/^\(positional\) /, '').split('.'); + const segs = path.split('.'); return segs[segs.length - 1] .replace(PROCESS_NAME_PATTERN, '') .replace(/\d+/g, '#'); }; - const leaves = [...new Set(diff.map((d) => leaf(d.path)))].sort(); + const leaves = [ + ...new Set( + diff.flatMap((d) => + d.only !== undefined + ? [`only-${d.only}`] + : d.fields.map((f) => leaf(f.path)), + ), + ), + ].sort(); return leaves.slice(0, 4).join(','); } @@ -356,7 +310,13 @@ 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; + if (!fails(routes)) return o; + // The first run's verdict stays on the row; the routes failure is in cls and symptom. + return { + ...routes, + comparison: o.comparison, + normalizations: o.normalizations, + }; } async function runPipeline( @@ -460,8 +420,6 @@ async function runPipeline( } finally { await dispose(); } - const n1 = normalizeIr(ir1); - const n3 = normalizeIr(ir3); stage = 'serialize2'; const xml2 = await serialize(ir3); @@ -478,28 +436,28 @@ async function runPipeline( if (routesShuffled) return finish({ cls: 'clean', stage, message: '' }); stage = 'compare'; - const comparison = compareModels(ir1, ir3); + const report = compareModels(ir1, ir3); + const comparison = report.level; + const normalizations = report.normalizations.map((n) => n.names); 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; + const diff = report.differences.flatMap(diffLines); + o.diffEntries = report.differences; return finish({ cls: pw.length > 0 ? 'warned-restructuring' : 'silent-change', comparison, + normalizations, stage: 'compare', message: diff.slice(0, 3).join('; '), diff, }); } - return finish({ cls: 'clean', comparison, stage: 'compare', message: '' }); + return finish({ + cls: 'clean', + comparison, + normalizations, + stage: 'compare', + message: '', + }); } catch (e) { if (e instanceof TimeoutError) { return finish({ cls: 'timeout', stage, message: e.message }); @@ -803,7 +761,7 @@ describe('round-trip fuzz', () => { 'message:', outcome.message, ...(outcome.diff - ? ['', 'diff (normalized ir1 -> ir3):', ...outcome.diff] + ? ['', 'diff (reduced ir1 -> ir3):', ...outcome.diff] : []), ...(outcome.dsl1 !== undefined ? ['', 'printed dsl1:', outcome.dsl1] diff --git a/tests/golden/README.md b/tests/golden/README.md index da7db33..8657541 100644 --- a/tests/golden/README.md +++ b/tests/golden/README.md @@ -59,10 +59,20 @@ Nested shapes stay inside their parent only through the `isExpanded` hint from ` 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. +Run the pair tests with `npm test --workspace tests`, or one pair with `npm test --workspace tests -- forms.round-trip.test.ts`. + +## Authoring rules + +Three rules keep a pair comparable across the round trip. + +- Unless a pair says otherwise, a variable read by a condition or an expression is declared as a form field rather than a `var`, so the declaration comes back out of the XML on import; the IR carries no `var` line. +- Every `throw`, `emit` and link catch is named, so its printed id re-parses unchanged; an unnamed one re-derives a coordinate id that moves when the restructured DSL reorders statements. +- No label equals the name humanized from its id, because the importer drops a label it can derive and the fixture would come back with it missing. + ## `invoice-approval-handwritten.bpmn` A BPMN file written by hand to look like real Operaton Modeler output. -It uses the `operaton:` namespace (`http://operaton.org/schema/1.0/bpmn`) for extension attributes, carries `` and `` children on every flow node in the MIWG style Operaton expects, sets `operaton:historyTimeToLive="P30D"` on the process, and includes a `` block with hand-picked coordinates. +It uses the `operaton:` namespace (`http://operaton.org/schema/1.0/bpmn`) for extension attributes, carries `` and `` children on every flow node in the MIWG style, sets `operaton:historyTimeToLive="P30D"` on the process, and includes a `` block with hand-picked coordinates. This is the input for the XML to IR direction. `packages/transform/test/xml-to-ir.test.ts` parses it and asserts the resulting IR matches the expected invoice-approval IR, covering both the import of a realistic file and the discarding of its diagram data. @@ -78,7 +88,7 @@ Contract: the process id `invoice-approval`, the userTask ids `ReviewInvoice` an ## `bad-service-task-no-binding.bpmn` A minimal one-process file whose single `` carries no execution binding: no `operaton:class`, `operaton:expression`, `operaton:delegateExpression`, and no external `operaton:type` and `operaton:topic` pair. -A service task with no execution form cannot be represented, so this is the negative-path fixture: `xmlToIr` must reject it with `UnsupportedServiceTaskFormError`, and `bpmns parse` must exit non-zero. +A service task with no execution form cannot be represented, so this is the negative-path fixture: `xmlToIr` must reject it with `UnsupportedServiceTaskFormError`, and `bpmns parse` must exit `1`. ## `structured-control-flow.bpmnscript` @@ -101,28 +111,24 @@ Contract: `isExpanded="true"` on each sub-process, nested children inside their ## `event-handlers.{bpmnscript,bpmn}` An order-processing narrative exercising the whole try/catch surface in one program: an `error` declaration carrying a message; a second whose code is not a word a name can spell, so the code sits on the declaration as a setting and the handler still refers to it by name; a third nothing raises or catches, which reaches the `.bpmn` as a root of its own; a payment `subprocess` that throws inside an `if`, escalates mid-chain with `emit escalation`, and owns an interrupting `on error` handler with both catch bindings; a process-level non-interrupting `on escalation ... alongside` handler; a catch-all `on error` handler; a terminal `throw escalation`; explicit ids on a throw and an emit with a `goto` targeting the named emit; and a process variable called `message` used in a condition, so the contextual event words coexist with same-named variables. -The condition variables are declared on the start form so they survive an import-and-back round trip, and each handler opens with an explicit trigger `start` and closes with an explicit `end`. +Each handler opens with an explicit trigger `start` and closes with an explicit `end`. Contract: the deduped roots (one per declared code, each carrying its declared name and each error root its message), the shared `errorRef` and `escalationRef`, `isInterrupting="false"` on the `alongside` handler, each handler shape inside its parent's bounds, and every authored id. ## `event-triggers.{bpmnscript,bpmn}` An order-fulfilment narrative exercising the remaining trigger set in one program: a process-level `on message("OrderCancelled")` handler; a `subprocess` owning a non-interrupting `on timer("PT2H", alongside)` reminder and a non-interrupting `on condition(stockLevel < 5, alongside)` watchdog reading a declared form variable; an `on signal("OrderFulfilled", alongside)` handler together with a continuing `emit signal Notify("OrderFulfilled")` and a terminal `throw signal Announce("OrderFulfilled")` of the same name; an `on timer(at: "...")` purge on the same sub-process, so one container carries three handlers; and a `var timer: string` read in a service expression, pinning that the timer trigger word coexists with a same-named variable. -The condition variable is declared on the start form so it survives an import-and-back round trip, and every throw and emit is explicitly named so its printed id re-parses cleanly. - Contract: the deduped name-keyed roots and their order, the shared `signalRef`, `isInterrupting="false"` on the `alongside` handlers, the single time-child per timer, each handler shape inside its parent's bounds, and every authored id. ## `compensation.{bpmnscript,bpmn}` A trip-booking saga exercising the whole undo surface in one program: two sub-processes each owning an `on compensation` undo block (the flight reverses in one step, the hotel through an `if` reading a declared form variable, so the undo logic is more than a single step); an `error` declaration carrying a message and a process-level `on error` handler whose body raises a named `emit compensation Undo` and then continues to notify the traveler; a matching `emit escalation Overspend` in the main flow and a process-level `on escalation` handler that gives up, recording the abandonment and ending its path with a named `throw compensation CancelAll`, for the cross-kind interplay; and a `var compensation: number` read in a service expression, pinning that the compensation word coexists with a same-named variable. -The `if` variables `seats` and `budget` are declared on the start form so they survive an import-and-back round trip, and every throw and emit is explicitly named so its printed id re-parses cleanly. Contract: the absence of any compensation root, the bare compensate definitions, the `triggeredByEvent` undo blocks with interrupting starts, each undo-block shape inside its host's bounds, and every authored id. ## `boundary-events.{bpmnscript,bpmn}` A parcel-dispatch narrative exercising the whole attached-handler surface in one program: six of the seven boundary triggers, all but `cancel`, which attaches to an `attempt` block alone and is frozen in the `transactions` pair; interrupting and non-interrupting attachment wherever Operaton permits both; five host kinds, a `user` task, a `class`-bound and a `topic`-bound `service` task, a `script` task, a `subprocess` whose escalation is raised by an `emit escalation` one container down, and a `call`; two hosts carrying two boundaries each, so the layout library has to distribute the attachers along the host's lower edge; an escape chain that rejoins the main flow through `goto`; an escape chain containing an `if`; and a host-less signal handler coexisting with all of them in the same container. -The `if` and condition variables are declared on the start form so they survive an import-and-back round trip, the escalation is emitted under an explicit id so its printed id re-parses cleanly, and every label differs from the name humanized from its id. Contract: every `attachedToRef`, `cancelActivity="false"` on each `alongside` boundary, the shared `signalRef` and `escalationRef`, each boundary shape centered on its host's bottom edge, and every authored id. @@ -150,7 +156,7 @@ A motor-claim settlement narrative carrying the flat engine settings, the ones w `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. +The header's `historyTimeToLive` is `P90D` rather than the `P30D` the exporter stamps on a process that wrote none, because the printer leaves that value out of the script 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 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. @@ -185,12 +191,11 @@ Contract: the `operaton:executionListener` and `operaton:taskListener` children ## `event-positions.{bpmnscript,bpmn}` -An order-dispatch narrative carrying a trigger in the three positions outside a handler in one program: a `start OrderReceived message("OrderReceived", ...)`, so the process is entered by a correlated message rather than by a caller while its start form still renders; an `emit message NotifyWarehouse("WarehouseNotified")` and a terminal `throw message OrderAcknowledged("OrderAcknowledged")`, the continuing and the terminal form of one verb pair; and an `end OrderAbandoned terminate(label: "Abandon every path")` in one branch of an `if`, beside the message end the other branch falls through to. +An order-dispatch narrative carrying a trigger in the three positions outside a handler in one program: a `start OrderReceived message("OrderReceived", ...)`, so the process is entered by a correlated message rather than by a caller while its start form still renders; an `emit message NotifyWarehouse("WarehouseNotified", ...)` and a terminal `throw message OrderAcknowledged("OrderAcknowledged", ...)`, the continuing and the terminal form of one verb pair; and an `end OrderAbandoned terminate(label: "Abandon every path")` in one branch of an `if`, beside the message end the other branch falls through to. The three message names are all distinct, so this artifact pins one derived root per name, where `event-triggers` pins the opposite case of several references collapsing onto one root. -The condition variable is declared on the start form so it survives an import-and-back round trip, and every throw and emit is explicitly named so its printed id re-parses cleanly. A terminate ends its branch, so the restructured DSL prints that branch as a `goto` onto the named service task instead of as an `if`/`else`; the IR is the same either way, which is what the idempotence block asserts. -Contract: one `bpmn:Message` root per distinct name in first-appearance order (`OrderReceived`, `WarehouseNotified`, `OrderAcknowledged`), the `messageRef` on the start event beside its `operaton:formData`, the `messageRef` on `NotifyWarehouse` and `OrderAcknowledged` with no implementation attribute so Operaton parses them as a none throw and a none end and sends nothing until a send surface exists, the bare `bpmn:terminateEventDefinition` on `OrderAbandoned` with its `name` attribute kept, an import of the frozen artifact that reports no warning at all and so none about that end event's label, and every authored id. +Contract: one `bpmn:Message` root per distinct name in first-appearance order (`OrderReceived`, `WarehouseNotified`, `OrderAcknowledged`), the `messageRef` on the start event beside its `operaton:formData`, the `messageRef` on `NotifyWarehouse` and `OrderAcknowledged` beside the `operaton:class` each sends through, the bare `bpmn:terminateEventDefinition` on `OrderAbandoned` with its `name` attribute kept, an import of the frozen artifact that reports no warning at all and so none about that end event's label, and every authored id. ## `task-kinds.{bpmnscript,bpmn}` @@ -219,7 +224,7 @@ A seat-booking narrative whose holding and paying are one block of work that can `attempt BookAndPay` holds the seats through an ordinary `subprocess` owning an `on compensation` undo block, charges the card, and issues the tickets; a guard on the declined charge ends that path with `end BookingAbandoned cancel(label: "Give up the booking")`, and `on BookAndPay: cancel` catches it. An `on BookAndPay: error` handler sits on the same block, so the artifact pins that a block taking a cancel handler still takes the handlers it always took. A second `attempt` block repeats over the seat rows, sets `asyncBefore`, and mentions cancel nowhere, so a block nothing gives up is frozen beside the one that is; it sits inside an ordinary `subprocess`, which nests the two heads inside one another both ways round. -The guard variable is declared on the start form and the collection the repeat clause names bare is declared in the header, so both survive an import-and-back round trip. +The collection the repeat clause names bare is declared in the header. Contract: the `attempt` blocks on `bpmn:transaction` and the ordinary ones on `bpmn:subProcess`; the `bpmn:cancelEventDefinition` on an end event inside the transaction and on a boundary event whose `attachedToRef` names that transaction, both of them bare; the error boundary on the same host; the `triggeredByEvent` undo block nested inside the block a cancel end gives up; `bpmn:multiInstanceLoopCharacteristics` and `operaton:asyncBefore` written on the second transaction tag; every nested shape inside its parent's bounds with both boundary shapes centered on the host's lower edge; and every authored id. @@ -238,8 +243,6 @@ A plain `await` follows the two, so both forms of the keyword are frozen side by A race lowers to a `bpmn:eventBasedGateway`, one `bpmn:intermediateCatchEvent` per branch, and a `bpmn:exclusiveGateway` merge, since the first branch to fire cancels the rest and exactly one of them ever runs. No flow out of that 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. 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 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}` @@ -264,11 +267,8 @@ Contract: four `bpmn:startEvent` elements, none carrying a `bpmn:incoming`; the ## `link-events.{bpmnscript,bpmn}` A claim-review narrative carrying a link pair at the process level and a second pair inside a sub-process, in one program: `ReworkMissingReceipts` and `ReworkRejected`, two `emit link` throws raised from an `if`/`else if` chain onto the one catch `AtRework`; `asyncBefore` on `AtRework`, legal on a catch because the engine runs it as an activity while a throw has none; `ToAudit` and `AtAudit` sitting together inside `Settlement`, both ends in one scope as the engine requires; and a catch chain holding an `if`/`else` that rejoins before its `goto` back into the main flow. -Two authoring rules keep the pair re-parsing under the same ids. -Every end of a pair is named, because an unnamed catch re-derives a coordinate id that moves when the restructured DSL reorders statements. Every chain that precedes a catch ends on an authored `end`, a `goto`, or an `emit link`, because a link catch takes no incoming flow and such an end prints under its reserved id and is refused when read back. -The label convention `boundary-events` follows above holds here too, and it protects the IR comparison rather than the ids: no label equals the name humanized from its id, so the header reads `process claim-review {` with no label, because the importer drops a name it can derive and a `label: "Claim Review"` would come back missing. -The condition variables are declared on the start form so they survive an import-and-back round trip. +The label rule applies to the header too, so it reads `process claim-review {` with no label, since a `label: "Claim Review"` would come back missing. An `emit link` inside a guard ends its branch, so the restructured DSL prints the guard as a `goto` onto the throw and the throw itself as a top-level statement after `end Closed`, because an intermediate throw has a chain the printer keeps at its authored scope, where a bare end has none; the IR is the same either way, which is what the idempotence block asserts. That order is pinned: `end Closed`, the two `emit link` throws, then `await link AtRework("Rework", asyncBefore: true)` and the rework chain with its `if`/`else` intact, ending in `goto AssessClaim`; inside the sub-process, `end Settled`, `emit link ToAudit("Audit")`, `await link AtAudit("Audit")`, the audit task, and `end Audited`. @@ -280,9 +280,6 @@ A gym-membership narrative carrying every extension a form field takes, spread o `Applied`'s form covers a string field with three built-in constraints (`required`, `minlength`, `maxlength`), a date field pinning its `pattern`, an `enum` field naming two labelled values and one bare value beside a `property` line, and a plain boolean default. `ConfirmPayment`'s form covers a number field bounded by `min`/`max`, a string field naming a custom `validator` class, a `readonly` string field carrying two `property` lines, and a required boolean field the `if` below reads. Every constraint sits on the type its validator accepts, the bounds on a `number` or a `string` field alone and the flags and `validator` on any type, because the engine deploys a mismatch and fails every submission rather than refusing the deployment. -Two authoring rules keep the pair re-parsing under the same ids. -Every element label differs from the name humanized from its id, because the importer drops a label it can derive and the fixture would otherwise come back with one missing. -The `if (confirmed)` condition reads a field declared on the `ConfirmPayment` form rather than a `var`, so the declaration comes back out of the XML on the way in. Contract: the `(element id, FormField[])` table in `tests/forms.round-trip.test.ts`, asserting `Applied` and `ConfirmPayment` keep every value, constraint, pattern and property at each hop and through import; and the frozen `plan` and `fullName` elements, pinning the child order (properties, validation, values) and that `required` writes with no `config`. @@ -291,10 +288,7 @@ Contract: the `(element id, FormField[])` table in `tests/forms.round-trip.test. A card-payment narrative carrying every extra a step handed to an external worker takes, spread over the three task kinds that bind to a topic. `ChargeCard` is a `service` task naming a numeric `taskPriority`, two `property` lines the worker fetches with the task, and two `error ... when` mappings in the order they raise; `SendReceipt` is a `send` task naming an expression `taskPriority` and one `property` line; `RateFraud` is a `decide` task naming one mapping, so a mapping outside the service tag is pinned too. `PAYMENT_DECLINED` is caught by a handler attached to `ChargeCard` and `GATEWAY_DOWN` by a process-wide one, so the artifact carries a code a handler uses beside one only the mappings raise. -Three authoring rules keep the pair re-parsing under the same ids. -Every element label differs from the name humanized from its id, because the importer drops a label it can derive and the fixture would otherwise come back with one missing. -The `amount` the receipt's priority reads is declared on the start form rather than a `var`, so the declaration comes back out of the XML on the way in. -Every mapping condition is written structured rather than as a raw `"${...}"` string, since a raw string prints back structured and would change what IR3 compares against. +Every mapping condition is written structured rather than as a raw `"${...}"` string, since a raw string prints back structured and the IR compared after the round trip would differ. Contract: the `EXTERNAL_BINDINGS` record in `tests/external-task.round-trip.test.ts`, asserting `ChargeCard`, `SendReceipt` and `RateFraud` keep their priority, properties and error mappings at each hop and through import; and the frozen artifact's `operaton:taskPriority`, `operaton:properties` and `operaton:errorEventDefinition` children, and the two `bpmn:error` roots in first-use order. @@ -306,7 +300,6 @@ The `while` carries `asyncAfter` and a retry cycle, and its body holds a plain ` 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. Every job setting in the artifact sits on a gateway, so a direction that moved one onto a neighbouring node would put an attribute where the contract allows none. The comparison at each hop keeps a join that carries a setting rather than inlining it as a pass-through the way `tests/helpers/normalize-ir.ts` treats every other synthesized join, because an inlined join takes its settings out of both sides of the comparison at once and a `join*` key one direction dropped would go unseen. @@ -317,7 +310,6 @@ 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. -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. @@ -327,12 +319,12 @@ The approve-review loop of an invoice process, the shape every approve-or-rework 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 two condition variables are declared as `var` lines, an exception to the form-field rule: the IR does not carry them, 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. +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` diff --git a/tests/golden/event-positions.bpmn b/tests/golden/event-positions.bpmn index a3df70d..7bf4823 100644 --- a/tests/golden/event-positions.bpmn +++ b/tests/golden/event-positions.bpmn @@ -17,7 +17,7 @@ Flow_ConfirmOrder_NotifyWarehouse Flow_NotifyWarehouse_Gateway_order-dispatch_3_split - + Flow_NotifyWarehouse_Gateway_order-dispatch_3_split @@ -42,7 +42,7 @@ Flow_Gateway_order-dispatch_3_join_OrderAcknowledged - + diff --git a/tests/golden/event-positions.bpmnscript b/tests/golden/event-positions.bpmnscript index 4bc2373..ca88227 100644 --- a/tests/golden/event-positions.bpmnscript +++ b/tests/golden/event-positions.bpmnscript @@ -24,7 +24,7 @@ process order-dispatch { user ConfirmOrder(label: "Confirm the order", assignee: "demo") - emit message NotifyWarehouse("WarehouseNotified") + emit message NotifyWarehouse("WarehouseNotified", class: "com.example.orders.NotifyWarehouseDelegate") if (amount > 1000) { service EscalateOrder(label: "Escalate the order", class: "com.example.orders.EscalateDelegate") @@ -33,5 +33,5 @@ process order-dispatch { service DispatchOrder(label: "Dispatch the order", class: "com.example.orders.DispatchDelegate") } - throw message OrderAcknowledged("OrderAcknowledged") + throw message OrderAcknowledged("OrderAcknowledged", class: "com.example.orders.AcknowledgeOrderDelegate") } diff --git a/tests/goto-fallback.round-trip.test.ts b/tests/goto-fallback.round-trip.test.ts index baf3eb5..6488696 100644 --- a/tests/goto-fallback.round-trip.test.ts +++ b/tests/goto-fallback.round-trip.test.ts @@ -128,8 +128,7 @@ describe('an edge with no statement form degrades without inventing a target, an [ // 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. + // `else fail`, so the recompiled split reaches nothing new. 'a split inside a loop body whose every route is conditioned keeps the route that stays in the loop', proc( 'ReviewLoop', @@ -165,7 +164,7 @@ describe('an edge with no statement form degrades without inventing a target, an ' goto Pay', ' } else if (!approved) {', ' user Review', - ' }', + ' } else fail', ' } while (clarified)', ' end End_2', ' user Pay', @@ -173,7 +172,7 @@ describe('an edge with no statement form degrades without inventing a target, an '}', '', ], - ['Approve->Approve', 'Approve->End_2'], + [], ], [ // A bare `goto` beside the fall-through would leave the rest @@ -401,15 +400,15 @@ const compiled = async (source: string) => astToIr(await parseToAst(source)); const compiledUnvalidated = async (source: string) => astToIr(await parseUnvalidated(source)); -// Fuzz-drawn shapes: each row pins the print's warning categories and whether -// it validates. +// Fuzz-drawn shapes: each row pins the print and its warnings, and the print +// validates. describe('composite shapes through compile, import and print', () => { it.each< [ title: string, shape: () => Promise, - categories: string[], - valid: boolean, + printed: string, + warnings: [category: string, elementId: string][], ] >([ [ @@ -432,8 +431,23 @@ describe('composite shapes through compile, import and print', () => { '}', ].join('\n'), ), + [ + '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', @@ -441,8 +455,19 @@ describe('composite shapes through compile, import and print', () => { compiled( 'process p {\n start S\n while (true) {\n user A\n end X\n }\n user B\n end Done\n}', ), + [ + 'process p {', + ' start S', + ' if (true) {', + ' user A', + ' end X', + ' }', + ' user B', + ' end Done', + '}', + '', + ].join('\n'), [], - true, ], [ 'a fork with an empty weighed branch beside an ending one prints whole', @@ -450,8 +475,21 @@ describe('composite shapes through compile, import and print', () => { compiled( 'process p {\n start S\n parallel {\n if (true) {\n }\n {\n end X\n }\n }\n end Done\n}', ), + [ + 'process p {', + ' start S', + ' parallel {', + ' if (true) {', + ' }', + ' {', + ' end X', + ' }', + ' }', + ' end Done', + '}', + '', + ].join('\n'), [], - true, ], [ 'a fork carrying settings with an ending branch prints whole', @@ -459,29 +497,70 @@ describe('composite shapes through compile, import and print', () => { 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}', ), + [ + 'process p {', + ' start S', + ' parallel (retryCycle: "R1/PT1M") {', + ' {', + ' user A', + ' }', + ' {', + ' end X', + ' }', + ' }', + ' end Done', + '}', + '', + ].join('\n'), [], - true, ], [ - 'a race branch holding a post-test loop whose body ends draws a jump into the branch', + 'a race branch holding a post-test loop whose body ends leaves the loop test out', () => 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, + [ + 'process p {', + ' start S', + ' await {', + ' timer("PT1M") {', + ' }', + ' message("m") {', + ' end X', + ' }', + ' }', + ' end Done', + '}', + '', + ].join('\n'), + [['unreachable', 'Gateway_p_1_b1_0_loop']], ], [ - 'a fork branch holding a post-test loop whose body ends draws a jump into the branch', + 'a fork branch holding a post-test loop whose body ends leaves the loop test out', () => 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, + [ + 'process p {', + ' start S', + ' parallel {', + ' {', + ' user F', + ' }', + ' if (true) {', + ' end S2', + ' }', + ' }', + ' end Done', + '}', + '', + ].join('\n'), + [['unreachable', 'Gateway_p_1_b1_0_loop']], ], [ - 'a post-test loop whose body ends prints its unreachable head as a jump', + 'a post-test loop whose body ends leaves its unreachable head and what follows it out', () => Promise.resolve( proc( @@ -503,22 +582,25 @@ describe('composite shapes through compile, import and print', () => { ], ), ), - [], - false, + ['process DanglingLoopHead {', ' start S', ' end X', '}', ''].join( + '\n', + ), + [ + ['unreachable', 'Gateway_L_loop'], + ['unreachable', 'Done'], + ], ], - ])('%s', async (_title, shape, categories, valid) => { + ])('%s', async (_title, shape, printedSource, printedWarnings) => { 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); + expect(printed.source).toBe(printedSource); + expect(printed.warnings.map((w) => [w.category, w.elementId])).toEqual( + printedWarnings, + ); + expect(await validationErrors(printed.source)).toEqual([]); }); }); diff --git a/tests/helpers/fuzz-generator.ts b/tests/helpers/fuzz-generator.ts index a01a71b..fb1fc08 100644 --- a/tests/helpers/fuzz-generator.ts +++ b/tests/helpers/fuzz-generator.ts @@ -1,5 +1,8 @@ // Fuzz program generator, printer and delta-debugging reducer. Programs are // best-effort valid; the harness keeps only validator-clean ones. +// +// Loaded outside vitest through Node's type stripping: import only the +// workspace package `@bpmn-script/language`. import { DECISION_RESULT_MAPPINGS, @@ -1700,8 +1703,11 @@ export class Generator { 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']); + // An unbound message throw sends nothing and draws a validator warning. + if (trigger === 'message') { + const key = r.chance(0.5) + ? r.pick(['class', 'expression', 'delegate', 'topic']) + : 'class'; items.push({ key, value: this.bindingValue(key) }); } if (r.chance(0.2)) items.push(...this.engineSettings()); @@ -1757,8 +1763,9 @@ export class Generator { } } + // As the validator: a block ends only if its last flow statement does. private blockTerminates(body: Stmt[]): boolean { - return body.some((s) => this.terminates(s)); + return this.terminates(body.findLast((s) => s.k !== 'on')); } private chain(scope: Scope, processLevel: boolean): Stmt[] { @@ -1924,6 +1931,13 @@ export class Generator { ) { continue; } + // A cancel handler on an attempt nothing gives up never runs, and the + // validator and importer both warn about it. + if ( + trigger === 'cancel' && + !body.some((s) => this.cancelEndIn(s, host!.name, false)) + ) + continue; const scopeKey = host ? `host:${host.name}` : `self`; const dupKey = `${scopeKey}|${trigger}|${items[0]?.value ?? ''}`; if (scope.usedHandlers.has(dupKey)) continue; @@ -2024,6 +2038,20 @@ export class Generator { } } + /** Whether attempt `name` holds a cancel end in its own flow, the scope Operaton cancels. */ + private cancelEndIn(s: Stmt, name: string, inside: boolean): boolean { + if (inside && s.k === 'end') return s.trigger === 'cancel'; + if (s.k === 'sub') { + if (s.name === name) + return s.body.some((x) => this.cancelEndIn(x, name, true)); + if (inside) return false; + } + if (inside && s.k === 'on' && s.host === undefined) return false; + return childBlocks(s).some((b) => + b.some((x) => this.cancelEndIn(x, name, inside)), + ); + } + private contains(s: Stmt, name: string): boolean { if ('name' in s && s.name === name) return true; for (const b of childBlocks(s)) diff --git a/tests/helpers/import-first.ts b/tests/helpers/import-first.ts index 8dce9c4..5117c80 100644 --- a/tests/helpers/import-first.ts +++ b/tests/helpers/import-first.ts @@ -14,10 +14,10 @@ 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.', + "the file's 'camunda' namespace was read as the 'operaton' namespace\n" + + "note: 'BpmnParse.OPERATON_BPMN_EXTENSIONS_NS' falls back to the " + + "'camunda' URI wherever the 'operaton' spelling is absent\n" + + "note: the rebuilt document carries 'operaton:' alone", }); export interface ImportFirst { diff --git a/tests/helpers/model-equivalence.test.ts b/tests/helpers/model-equivalence.test.ts index a8f5a43..63ca772 100644 --- a/tests/helpers/model-equivalence.test.ts +++ b/tests/helpers/model-equivalence.test.ts @@ -1,7 +1,12 @@ import { describe, it, expect } from 'vitest'; -import { astToIr } from '@bpmn-script/transform'; +import { astToIr, HISTORY_TIME_TO_LIVE } from '@bpmn-script/transform'; import type { BpmnProcess, FlowElement } from '@bpmn-script/transform'; -import { compareModels, type ModelComparison } from './model-equivalence.js'; +import { + compareModels, + type ModelComparison, + type ModelReport, + type NormalizationName, +} from './model-equivalence.js'; import { parseToAst } from './pipeline.js'; const compile = async (source: string) => astToIr(await parseToAst(source)); @@ -307,10 +312,35 @@ const chainedJoins = (end: string): BpmnProcess => `Gateway_b_join -> ${end}`, ], ); -const split = (routes: string[]): BpmnProcess => +const split = (routes: string[], kind = 'exclusiveGateway'): BpmnProcess => + model({ T: 'userTask', G: kind, A: 'userTask', B: 'userTask' }, [ + 'T -> G', + ...routes, + ]); +// A parallel fork F into A and, through a second fork G, into B and C, or F +// into all three; `condition` goes on the route into C. +const innerFork = (condition = ''): BpmnProcess => model( - { T: 'userTask', G: 'exclusiveGateway', A: 'userTask', B: 'userTask' }, - ['T -> G', ...routes], + { + S: 'startEvent', + F: 'parallelGateway', + G: 'parallelGateway', + A: 'userTask', + B: 'userTask', + C: 'userTask', + }, + ['S -> F', 'F -> A', 'F -> G', 'G -> B', `G -> C${condition}`], + ); +const flatFork = (condition = ''): BpmnProcess => + model( + { + S: 'startEvent', + F: 'parallelGateway', + A: 'userTask', + B: 'userTask', + C: 'userTask', + }, + ['S -> F', 'F -> A', 'F -> B', `F -> C${condition}`], ); const documented = (ir: BpmnProcess, id: string): BpmnProcess => ({ ...ir, @@ -346,6 +376,381 @@ const nestedSplits = ( ].map((flow, n) => flowId(n) + flow), ); +const withFields = ( + ir: BpmnProcess, + id: string, + fields: Record, +): BpmnProcess => ({ + ...ir, + flowElements: ir.flowElements.map((el) => + el.id === id ? ({ ...el, ...fields } as FlowElement) : el, + ), +}); +const message = (messageName: string) => ({ + eventDefinition: { kind: 'message', messageName }, +}); +// A task, then a race between two message catches, each to its own task. +const race = (catchId: string): BpmnProcess => + withFields( + withFields( + model( + { + T: 'userTask', + R: 'eventBasedGateway', + [catchId]: 'intermediateCatchEvent', + Catch_p_0_b1: 'intermediateCatchEvent', + A: 'userTask', + B: 'userTask', + }, + ['T -> R', `R -> ${catchId}`, 'R -> Catch_p_0_b1', `${catchId} -> A`], + ), + catchId, + message('Paid'), + ), + 'Catch_p_0_b1', + message('Cancelled'), + ); +const choice = (gateway = 'G', condition = '${a}'): BpmnProcess => + model( + { + T: 'userTask', + [gateway]: 'exclusiveGateway', + A: 'userTask', + B: 'userTask', + }, + [`T -> ${gateway}`, `${gateway} -> A ? ${condition}`, `${gateway} -> B`], + ); +const reversed = (ir: BpmnProcess): BpmnProcess => ({ + ...ir, + flowElements: [...ir.flowElements].reverse(), + sequenceFlows: [...ir.sequenceFlows].reverse(), +}); +const merged = (merge: string): BpmnProcess => + model({ A: 'userTask', [merge]: 'exclusiveGateway', B: 'userTask' }, [ + `f1: A -> ${merge}`, + `f2: ${merge} -> B`, + ]); +const singleRoute = (kind: string, condition: string): BpmnProcess => + model({ A: 'userTask', M: kind, B: 'userTask' }, [ + 'A -> M', + `M -> B${condition}`, + ]); + +const boundaryEscape = (endId: string): BpmnProcess => { + const ir = model( + { + A: 'userTask', + Boundary_x: 'boundaryEvent', + [endId]: 'endEvent', + }, + ['Boundary_x -> ' + endId], + ); + return withFields(ir, 'Boundary_x', { + attachedToRef: 'A', + eventDefinition: message('Stop').eventDefinition, + }); +}; + +const bridged = ( + level: ModelComparison, + names: NormalizationName[], + elements: string[], +): ModelReport => ({ + level, + normalizations: [{ names, elements }], + differences: [], +}); +// The gateway that stays, and its flows, against the one flow that replaces +// them where the gateway is inlined. +const keptGateway = (kind: string): ModelReport => { + const gateway = `Gateway_${kind}_[in:A]_[out:B]`; + return { + level: 'changed', + normalizations: [], + differences: [ + gateway, + `Flow_A_${gateway}`, + `Flow_${gateway}_B`, + 'Flow_A_B', + ].map((element, i) => ({ + container: 'p', + element, + only: i < 3 ? 'a' : 'b', + fields: [], + })), + }; +}; +const CHOICE = 'Gateway_exclusiveGateway_[in:T]_[out:A,B]'; +const SPLIT = 'Gateway_p_0_split'; +const MERGE = 'Gateway_exclusiveGateway_[in:T]_[out:A]'; +const FORK = 'Gateway_parallelGateway_[in:T]_[out:B,C]'; +const JOIN = 'Gateway_exclusiveGateway_[in:A,B]_[out:C]'; +const OUTER_FORK = 'Gateway_parallelGateway_[in:S]_[out:B,C]'; +// A start and a task, then the end `endId` or none. +const stops = (endId?: string): BpmnProcess => + endId === undefined + ? model({ S: 'startEvent', T: 'userTask' }, ['S -> T']) + : model({ S: 'startEvent', T: 'userTask', [endId]: 'endEvent' }, [ + 'S -> T', + `T -> ${endId}`, + ]); + +const reportTable: [ + title: string, + a: BpmnProcess, + b: BpmnProcess, + want: ModelReport, +][] = [ + [ + 'identical models', + choice(), + choice(), + { level: 'same', normalizations: [], differences: [] }, + ], + [ + 'a race whose catch events carry modeler ids', + race('Event_0abc'), + race('Catch_p_0_b0'), + bridged( + 'canonical', + ['synthesized-ids'], + ['p/Catch_p_0_b0', 'p/Event_0abc', 'p/Flow_1', 'p/Flow_3'], + ), + ], + [ + 'a hand-named and a synthesized split', + choice('G'), + choice('Gateway_p_0_split'), + bridged( + 'same', + ['synthesized-ids'], + ['p/Flow_0', 'p/Flow_1', 'p/Flow_2', 'p/G', 'p/Gateway_p_0_split'], + ), + ], + [ + 'a named and an unnamed gateway', + withFields(choice(), 'G', { name: 'Paid?' }), + choice(), + bridged('same', ['gateway-names'], [`p/${CHOICE}`]), + ], + [ + 'an extra settings-free merge', + model({ T: 'userTask', M: 'exclusiveGateway', A: 'userTask' }, [ + 'T -> M', + 'M -> A', + ]), + model({ T: 'userTask', A: 'userTask' }, ['T -> A']), + bridged( + 'same', + ['pass-through-gateways'], + [`p/Flow_${MERGE}_A`, 'p/Flow_T_A', `p/Flow_T_${MERGE}`, `p/${MERGE}`], + ), + ], + [ + 'reversed element order', + forked('parallelGateway'), + reversed(forked('parallelGateway')), + bridged('same', ['element-order'], ['p']), + ], + [ + 'a single-quoted and a double-quoted JUEL string', + choice('G', "${x == 'a'}"), + choice('G', '${x == "a"}'), + bridged('canonical', ['expression-parse-trees'], [`p/Flow_${CHOICE}_A`]), + ], + [ + 'an original without a history time to live against a rebuild with the default', + choice(), + { ...choice(), historyTimeToLive: HISTORY_TIME_TO_LIVE }, + bridged('same', ['default-history-time-to-live'], ['p']), + ], + [ + 'an original without a history time to live against a rebuild with a non-default value is a difference', + choice(), + { ...choice(), historyTimeToLive: 'P7D' }, + { + level: 'changed', + normalizations: [], + differences: [ + { + container: 'p', + element: 'p', + fields: [{ path: 'historyTimeToLive', b: 'P7D' }], + }, + ], + }, + ], + [ + 'a pass-through merge under different gateway ids on each side, flow ids equal, is bridged only by synthesized ids and pass-through gateways together', + merged('M'), + merged('N'), + bridged( + 'same', + ['synthesized-ids', 'pass-through-gateways'], + ['p/M', 'p/N', 'p/f1', 'p/f2'], + ), + ], + [ + 'a changed pair reports no normalizations, though switching some off re-keys or widens its remaining difference', + withFields(choice(SPLIT), SPLIT, { name: 'Paid?', documentation: 'x' }), + withFields(choice(SPLIT), SPLIT, { documentation: 'y' }), + { + level: 'changed', + normalizations: [], + differences: [ + { + container: 'p', + element: CHOICE, + fields: [{ path: 'documentation', a: 'x', b: 'y' }], + }, + ], + }, + ], + [ + 'a boundary-escape end named for a hand-named boundary against the container end', + boundaryEscape('EndEvent_Boundary_x'), + boundaryEscape('EndEvent_p'), + bridged( + 'canonical', + ['synthesized-ids'], + ['p/EndEvent_Boundary_x', 'p/EndEvent_p', 'p/Flow_0'], + ), + ], + [ + 'a branch that stops against the same branch closed by the minted end', + stops(), + stops('EndEvent_p'), + { + level: 'canonical', + normalizations: [ + { names: ['synthesized-ids'], elements: ['p/EndEvent_p', 'p/Flow_1'] }, + { + names: ['implicit-end'], + elements: ['p/Flow_T_endEvent:', 'p/endEvent:'], + }, + ], + differences: [], + }, + ], + ...( + [ + ['an authored id', stops('Done'), 'Done'], + [ + 'a name', + withFields(stops('EndEvent_p'), 'EndEvent_p', { name: 'x' }), + 'endEvent:', + ], + ] as const + ).map(([what, b, end]): [string, BpmnProcess, BpmnProcess, ModelReport] => [ + `a branch that stops against an end with ${what} is a difference`, + stops(), + b, + { + level: 'changed', + normalizations: [], + differences: [end, `Flow_T_${end}`].map((element) => ({ + container: 'p', + element, + only: 'b', + fields: [], + })), + }, + ]), + [ + 'an unconditioned last route of an exclusive split against the same route as its default', + split(['G -> A ? ${a}', 'G -> B']), + withFields(split(['G -> A ? ${a}', 'G -> B']), 'G', { + defaultFlowId: 'Flow_2', + }), + bridged('same', ['unconditioned-last-route'], [`p/${CHOICE}`]), + ], + [ + 'a settings-free fork behind a fork against one flat fork', + innerFork(), + flatFork(), + { + level: 'same', + normalizations: [ + { + names: ['synthesized-ids'], + elements: ['p/Flow_2', 'p/Flow_3', 'p/Flow_4'], + }, + { + names: ['nested-forks'], + elements: [ + `p/Flow_${OUTER_FORK}_B`, + `p/Flow_${OUTER_FORK}_C`, + 'p/Flow_S_B', + 'p/Flow_S_C', + `p/Flow_S_${OUTER_FORK}`, + `p/${OUTER_FORK}`, + ], + }, + ], + differences: [], + }, + ], + [ + 'two flows leaving a task against a parallel fork after it', + model({ T: 'userTask', B: 'userTask', C: 'userTask' }, [ + 'f1: T -> B', + 'f2: T -> C', + ]), + model( + { T: 'userTask', F: 'parallelGateway', B: 'userTask', C: 'userTask' }, + ['f0: T -> F', 'f1: F -> B', 'f2: F -> C'], + ), + bridged( + 'same', + ['uncontrolled-flow'], + [ + `p/Flow_${FORK}_B`, + `p/Flow_${FORK}_C`, + 'p/Flow_T_B', + 'p/Flow_T_C', + `p/Flow_T_${FORK}`, + `p/${FORK}`, + ], + ), + ], + [ + 'two flows entering a task against an exclusive merge in front of it', + model({ A: 'userTask', B: 'userTask', C: 'userTask' }, [ + 'A -> C', + 'B -> C', + ]), + model( + { A: 'userTask', B: 'userTask', M: 'exclusiveGateway', C: 'userTask' }, + ['A -> M', 'B -> M', 'M -> C'], + ), + bridged( + 'same', + ['uncontrolled-flow'], + [ + 'p/Flow_A_C', + `p/Flow_A_${JOIN}`, + 'p/Flow_B_C', + `p/Flow_B_${JOIN}`, + `p/Flow_${JOIN}_C`, + `p/${JOIN}`, + ], + ), + ], + ...['exclusiveGateway', 'inclusiveGateway'].map( + (kind): [string, BpmnProcess, BpmnProcess, ModelReport] => [ + `two models that differ only in the condition on the single outgoing flow of an ${kind} are reported as differing`, + singleRoute(kind, ' ? ${x}'), + singleRoute(kind, ''), + keptGateway(kind), + ], + ), +]; + +// Documented, so it is not read as T's own split. +const documentedInclusive = documented( + split(['G -> A ? ${a}', 'G -> B'], 'inclusiveGateway'), + 'G', +); const builtTable: [ title: string, a: BpmnProcess, @@ -407,14 +812,181 @@ const builtTable: [ documented(nestedSplits(['G1', 'G2'], 'M'), 'M'), 'changed', ], + // Each row breaks one condition for an end to stand for a branch that + // stops, so dropping that one guard turns its row red. + [ + 'a minted end reached on a condition is changed', + stops(), + model({ S: 'startEvent', T: 'userTask', EndEvent_p: 'endEvent' }, [ + 'S -> T', + 'T -> EndEvent_p ? ${x}', + ]), + 'changed', + ], + [ + 'a minted end beside another flow of its source is changed', + model({ S: 'startEvent', T: 'userTask', B: 'userTask' }, [ + 'S -> T', + 'f: T -> B', + ]), + model( + { + S: 'startEvent', + T: 'userTask', + B: 'userTask', + EndEvent_p: 'endEvent', + }, + ['S -> T', 'f: T -> B', 'T -> EndEvent_p'], + ), + 'changed', + ], + // Each row breaks one condition for a route or a fork to compare as the + // printer's rewrite of it, so dropping that one guard turns its row red. + [ + 'an unconditioned last route of an inclusive gateway against the same route as its default is changed', + documentedInclusive, + withFields(documentedInclusive, 'G', { defaultFlowId: 'Flow_2' }), + 'changed', + ], + [ + 'an unconditioned route ahead of a conditioned one at an exclusive split against the same route as its default is changed', + split(['G -> A', 'G -> B ? ${a}']), + withFields(split(['G -> A', 'G -> B ? ${a}']), 'G', { + defaultFlowId: 'Flow_1', + }), + 'changed', + ], + [ + 'a fork with a job setting behind a fork against one flat fork is changed', + withFields(innerFork(), 'G', { asyncBefore: true }), + flatFork(), + 'changed', + ], + [ + 'a fork with a conditioned route behind a fork against one flat fork is changed', + innerFork(' ? ${c}'), + flatFork(' ? ${c}'), + 'changed', + ], + [ + 'a fork behind a fork that continues after its job against one flat fork is changed', + withFields(innerFork(), 'F', { asyncAfter: true }), + withFields(flatFork(), 'F', { asyncAfter: true }), + 'changed', + ], + [ + 'an exclusive split behind a fork against one flat fork is restructured', + withFields(innerFork(), 'G', { kind: 'exclusiveGateway' }), + flatFork(), + 'restructured', + ], + [ + 'a fork behind an exclusive split against one flat split is changed', + withFields(innerFork(), 'F', { kind: 'exclusiveGateway' }), + withFields(flatFork(), 'F', { kind: 'exclusiveGateway' }), + 'changed', + ], + [ + 'a fork that a second flow also enters behind a fork against one flat fork is changed', + model( + { + S: 'startEvent', + T: 'userTask', + F: 'parallelGateway', + G: 'parallelGateway', + A: 'userTask', + B: 'userTask', + C: 'userTask', + }, + ['S -> F', 'F -> A', 'F -> G', 'T -> G', 'G -> B', 'G -> C'], + ), + model( + { + S: 'startEvent', + T: 'userTask', + F: 'parallelGateway', + A: 'userTask', + B: 'userTask', + C: 'userTask', + }, + ['S -> F', 'F -> A', 'F -> B', 'F -> C'], + ), + 'changed', + ], + [ + 'a fork behind a fork on a conditioned flow against one flat fork is changed', + model( + { + S: 'startEvent', + F: 'parallelGateway', + G: 'parallelGateway', + A: 'userTask', + B: 'userTask', + C: 'userTask', + }, + ['S -> F', 'F -> A', 'F -> G ? ${c}', 'G -> B', 'G -> C'], + ), + flatFork(), + 'changed', + ], + [ + 'a fork with no route out behind a fork against the outer fork alone is restructured', + model( + { + S: 'startEvent', + F: 'parallelGateway', + G: 'parallelGateway', + A: 'userTask', + B: 'userTask', + }, + ['S -> F', 'F -> A', 'F -> B', 'F -> G'], + ), + model( + { S: 'startEvent', F: 'parallelGateway', A: 'userTask', B: 'userTask' }, + ['S -> F', 'F -> A', 'F -> B'], + ), + 'restructured', + ], + [ + 'two forks that only feed each other, splitting their routes differently, are restructured', + model( + { + S: 'startEvent', + E: 'endEvent', + G1: 'parallelGateway', + G2: 'parallelGateway', + A: 'userTask', + B: 'userTask', + C: 'userTask', + }, + ['S -> E', 'G1 -> G2', 'G1 -> A', 'G2 -> G1', 'G2 -> B', 'G2 -> C'], + ), + model( + { + S: 'startEvent', + E: 'endEvent', + G1: 'parallelGateway', + G2: 'parallelGateway', + A: 'userTask', + B: 'userTask', + C: 'userTask', + }, + ['S -> E', 'G1 -> G2', 'G1 -> A', 'G1 -> B', 'G2 -> G1', 'G2 -> C'], + ), + 'restructured', + ], ]; describe('compareModels', () => { + it.each(reportTable)('%s', (_title, a, b, want) => { + expect(compareModels(a, b)).toEqual(want); + }); + it.each(table)('%s', async (_title, a, b, want) => { - expect(compareModels(await compile(a), await compile(b))).toBe(want); + expect(compareModels(await compile(a), await compile(b)).level).toBe(want); }); it.each(builtTable)('%s', (_title, a, b, want) => { - expect(compareModels(a, b)).toBe(want); + expect(compareModels(a, b).level).toBe(want); }); }); diff --git a/tests/helpers/model-equivalence.ts b/tests/helpers/model-equivalence.ts index 52b6ad0..4ac85f4 100644 --- a/tests/helpers/model-equivalence.ts +++ b/tests/helpers/model-equivalence.ts @@ -2,29 +2,304 @@ // `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. +// +// Loaded outside vitest through Node's type stripping: import only the +// workspace packages and `normalize-ir.ts`, with `.ts` extensions. -import { isGateway, parseJuel } from '@bpmn-script/transform'; -import type { BpmnProcess, FlowElement } from '@bpmn-script/transform'; -import { normalizeIr } from './normalize-ir.js'; +import { + HISTORY_TIME_TO_LIVE, + isGateway, + isSynthesizedTerminalId, + parseJuel, +} from '@bpmn-script/transform'; +import type { + BpmnProcess, + FlowContainer, + FlowElement, + SequenceFlow, +} from '@bpmn-script/transform'; +import { + NORMALIZATIONS, + hasOnly, + isPlainFlow, + normalizeIr, + type NormalizationName, +} from './normalize-ir.ts'; + +export { NORMALIZATIONS, normalizeIr, type NormalizationName }; 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'; +export interface AppliedNormalization { + /** One name, or a minimal group needed only together, in `NORMALIZATIONS` order. */ + names: NormalizationName[]; + /** Keys of the differences that appear only with these switched off, sorted. */ + elements: string[]; +} + +export interface ElementDifference { + /** Container path from the process, ids joined by '/'. */ + container: string; + /** Element or flow key, or the process's own id for its own fields. */ + element: string; + only?: 'a' | 'b'; + /** Differing fields as dotted paths; empty when `only` is set. */ + fields: { path: string; a?: unknown; b?: unknown }[]; +} + +export interface ModelReport { + level: ModelComparison; + normalizations: AppliedNormalization[]; + /** Measured on the fully reduced models: empty iff `level` is `same` or `canonical`. */ + differences: ElementDifference[]; +} + +export function compareModels(a: BpmnProcess, b: BpmnProcess): ModelReport { + const bridged = withDefaultTimeToLive(a, b); + const differences = diffModels(reduce(bridged), reduce(b)); + const level: ModelComparison = + stable(normalizeIr(bridged)) === stable(normalizeIr(b)) + ? 'same' + : stable(reduce(bridged)) === stable(reduce(b)) + ? 'canonical' + : stable(modelSignature(bridged)) === stable(modelSignature(b)) + ? 'restructured' + : 'changed'; + return { + level, + normalizations: differences.length === 0 ? needed(a, bridged, b) : [], + differences, + }; +} + +// One way only: the exporter writes `HISTORY_TIME_TO_LIVE` for an original +// (`a`) that sets none, so a rebuild (`b`) carrying it is expected. A rebuild +// that loses a value the original set is a real change. +function withDefaultTimeToLive(a: BpmnProcess, b: BpmnProcess): BpmnProcess { + return a.historyTimeToLive === undefined && + b.historyTimeToLive === HISTORY_TIME_TO_LIVE + ? { ...a, historyTimeToLive: HISTORY_TIME_TO_LIVE } + : a; +} + +// Expressions are canonicalized before ids: a minted coordinate name embeds +// its event definition. +function reduce( + ir: BpmnProcess, + off: ReadonlySet = new Set(), +): BpmnProcess { + const expressions = off.has('expression-parse-trees') + ? ir + : canonicalExpressions(ir); + const normalized = normalizeIr( + off.has('synthesized-ids') + ? expressions + : canonicalizeCoordinateIds(expressions), + off, + ); + return off.has('implicit-end') ? normalized : withoutImplicitEnds(normalized); +} + +// A minted none end event, as `canonicalizeCoordinateIds` renames it. +const MINTED_END = /^endEvent:(#\d+)?$/; + +// Drops each minted, settings-free none end event whose every incoming flow is +// plain and its source's only outgoing one, the source being no gateway: a +// gateway left with no flow runs its own code, not the one below. Operaton ends a token that finds +// no outgoing flow with the `execution.end(true)` a none end event runs +// (`BpmnActivityBehavior.handleNoTransitions`), and with `asyncAfter` the +// activity end is then the async step, so the job count matches too. Only a +// minted id marks an end as one the compiler wrote for a branch without +// `end`, so this needs `synthesized-ids`. It runs after `normalizeIr`, so +// pass-through gateways are already gone. +function withoutImplicitEnds(container: T): T { + const byId = new Map(container.flowElements.map((el) => [el.id, el])); + const fanOut = (id: string): number => + container.sequenceFlows.filter((f) => f.sourceRef === id).length; + const ends = new Set( + container.flowElements + .filter((el) => el.kind === 'endEvent' && MINTED_END.test(el.id)) + .filter((el) => hasOnly(el, ['kind', 'id'])) + .map((el) => el.id) + .filter((id) => { + const incoming = container.sequenceFlows.filter( + (f) => f.targetRef === id, + ); + return incoming.every( + (f) => + isPlainFlow(f) && + fanOut(f.sourceRef) === 1 && + !isGateway(byId.get(f.sourceRef)!), + ); + }), + ); + return { + ...container, + flowElements: container.flowElements + .filter((el) => !ends.has(el.id)) + .map((el) => (el.kind === 'subProcess' ? withoutImplicitEnds(el) : el)), + sequenceFlows: container.sequenceFlows.filter( + (f) => !ends.has(f.targetRef), + ), + }; +} + +// Only for pairs whose reduced models are equal: on a differing pair, switching +// `synthesized-ids` off re-labels an existing difference's key, which would +// read as a new one. A normalization is needed when switching it off alone +// yields a difference. Two that overlap (each suffices alone) are needed by +// neither, so the smallest jointly needed groups stand in for them. +function needed( + original: BpmnProcess, + bridged: BpmnProcess, + b: BpmnProcess, +): AppliedNormalization[] { + const a = (off: ReadonlySet) => + off.has('default-history-time-to-live') ? original : bridged; + const switchedOff = (names: NormalizationName[]) => { + const off = new Set(names); + const added = diffModels(reduce(a(off), off), reduce(b, off)); + const elements = [...new Set(added.map(keyOf))].sort(); + return { added: added.length, entry: { names, elements } }; + }; + + const singles = NORMALIZATIONS.map((n) => switchedOff([n])) + .filter(({ added }) => added > 0) + .map(({ entry }) => entry); + if (singles.length > 0) return singles; + + const allOff = new Set(NORMALIZATIONS); + const identicalWithEverythingOff = + stable(reduce(original, allOff)) === stable(reduce(b, allOff)); + if (identicalWithEverythingOff) return []; + + for (let size = 2; size <= NORMALIZATIONS.length; size++) { + const groups = subsets(NORMALIZATIONS, size) + .map(switchedOff) + .filter(({ added }) => added > 0) + .map(({ entry }) => entry); + if (groups.length > 0) return groups; } - return 'changed'; + return []; } -// Expressions go first: a minted coordinate name embeds its event definition. -function canonicalForm(ir: BpmnProcess): string { - return stable( - normalizeIr(canonicalizeCoordinateIds(canonicalExpressions(ir))), +function subsets(xs: readonly T[], size: number): T[][] { + if (size === 0) return [[]]; + return xs.flatMap((x, i) => + subsets(xs.slice(i + 1), size - 1).map((rest) => [x, ...rest]), ); } +function keyOf(d: ElementDifference): string { + return d.container === d.element + ? d.container + : `${d.container}/${d.element}`; +} + +function diffModels(a: BpmnProcess, b: BpmnProcess): ElementDifference[] { + const out: ElementDifference[] = []; + diffContainer(a, b, a.id, a.id, out); + return out; +} + +// A sub-process's own fields and body order are an entry of its parent; its +// body's elements are entries of its own path. +function diffContainer( + a: FlowContainer, + b: FlowContainer, + parent: string, + element: string, + out: ElementDifference[], +): void { + const path = parent === element ? parent : `${parent}/${element}`; + const { flowElements: _ae, sequenceFlows: _af, ...ownA } = a; + const { flowElements: _be, sequenceFlows: _bf, ...ownB } = b; + const elementsA = keyed(a.flowElements, a.sequenceFlows); + const elementsB = keyed(b.flowElements, b.sequenceFlows); + + // With `element-order` on both sides are sorted, so only an order that + // normalization left alone can show up here. + const orderOf = ( + own: Map, + other: Map, + flows: boolean, + ): string[] => + [...own] + .filter(([k, x]) => other.has(k) && 'sourceRef' in x === flows) + .map(([k]) => k); + const fields = fieldDiffs(ownA, ownB); + for (const [field, flows] of [ + ['flowElements', false], + ['sequenceFlows', true], + ] as const) { + const orderA = orderOf(elementsA, elementsB, flows); + const orderB = orderOf(elementsB, elementsA, flows); + if (stable(orderA) !== stable(orderB)) { + fields.push({ path: field, a: orderA, b: orderB }); + } + } + if (fields.length > 0) out.push({ container: parent, element, fields }); + + for (const [key, x] of elementsA) { + const y = elementsB.get(key); + if (y === undefined) { + out.push({ container: path, element: key, only: 'a', fields: [] }); + } else if ('flowElements' in x && 'flowElements' in y) { + diffContainer(x, y, path, key, out); + } else { + const fields = fieldDiffs(x, y); + if (fields.length > 0) + out.push({ container: path, element: key, fields }); + } + } + for (const key of elementsB.keys()) { + if (!elementsA.has(key)) { + out.push({ container: path, element: key, only: 'b', fields: [] }); + } + } +} + +// Ids, with an occurrence suffix: re-keyed flows between one pair of nodes +// (a conditioned route and its fallback) share an id. +function keyed( + elements: FlowElement[], + flows: SequenceFlow[], +): Map { + const seen = new Map(); + return new Map( + [...elements, ...flows].map((x) => { + const n = (seen.get(x.id) ?? 0) + 1; + seen.set(x.id, n); + return [n === 1 ? x.id : `${x.id} #${n}`, x]; + }), + ); +} + +function fieldDiffs( + a: unknown, + b: unknown, + path = '', + out: ElementDifference['fields'] = [], +): ElementDifference['fields'] { + if (stable(a) === stable(b)) return out; + const isRecord = (x: unknown): x is Record => + x !== null && typeof x === 'object' && !Array.isArray(x); + if (isRecord(a) && isRecord(b)) { + for (const k of [ + ...new Set([...Object.keys(a), ...Object.keys(b)]), + ].sort()) { + fieldDiffs(a[k], b[k], path === '' ? k : `${path}.${k}`, out); + } + } else { + out.push({ + path, + ...(a === undefined ? {} : { a }), + ...(b === undefined ? {} : { b }), + }); + } + return out; +} + // `toEqual` semantics on a string: sorted keys, `undefined` dropped. export function stable(x: unknown): string { return JSON.stringify(x, (_k, v: unknown) => { @@ -67,14 +342,11 @@ function canonicalExpressions(value: T): T { ) 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)$/; - +// Minted 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). A race branch's catch has no id slot in the script, so it is +// recognised by position whatever its id. Gateways need no rename here: +// `normalizeIr` re-keys every one, and the signature never prints their ids. function canonicalizeCoordinateIds(container: BpmnProcess): BpmnProcess { const rename = new Map(); const used = new Map(); @@ -92,45 +364,21 @@ function canonicalizeCoordinateIds(container: BpmnProcess): BpmnProcess { ) as unknown as FlowElement) : el, ); + const kindOf = new Map(container.flowElements.map((e) => [e.id, e.kind])); + const raceBranches = new Set( + container.sequenceFlows + .filter((f) => kindOf.get(f.sourceRef) === 'eventBasedGateway') + .map((f) => f.targetRef), + ); for (const el of elements) { - if (COORDINATE_ID.test(el.id)) { + if (el.kind === 'subProcess' && el.triggeredByEvent === true) { + mint(el.id, `EventSubProcess:${handlerContent(el)}`); + } else if ( + isSynthesizedTerminalId(el.id, el.kind, container) || + (el.kind === 'intermediateCatchEvent' && raceBranches.has(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}`, - ); } } diff --git a/tests/helpers/normalize-ir.test.ts b/tests/helpers/normalize-ir.test.ts index 4983852..0c54827 100644 --- a/tests/helpers/normalize-ir.test.ts +++ b/tests/helpers/normalize-ir.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from 'vitest'; -import { normalizeIr } from './normalize-ir.js'; +import { + normalizeIr, + NORMALIZATIONS, + type NormalizationName, +} from './normalize-ir.js'; import type { BpmnProcess, EventDefinition, @@ -299,3 +303,299 @@ describe('normalizeIr', () => { expect(normalizeIr(ir)).toEqual({ ...ir, flowElements, sequenceFlows }); }); }); + +const tasks = (...ids: string[]): FlowElement[] => + ids.map((id) => ({ kind: 'userTask', id })); +const flow = (id: string, sourceRef: string, targetRef: string) => ({ + id, + sourceRef, + targetRef, +}); +const nested = (gateway: string, dflt: string): BpmnProcess => + process( + [ + { + kind: 'subProcess', + id: 'S', + flowElements: [ + ...tasks('A', 'B', 'C'), + { kind: 'exclusiveGateway', id: gateway, defaultFlowId: dflt }, + ], + sequenceFlows: [ + flow('f1', 'A', gateway), + { ...flow('f2', gateway, 'B'), conditionExpression: '${x}' }, + flow(dflt, gateway, 'C'), + ], + }, + ], + [], + ); +const fork = (gateway: FlowElement, flowIds: [string, string, string]) => + process( + [...tasks('A', 'B', 'C'), gateway], + [ + flow(flowIds[0], 'A', gateway.id), + flow(flowIds[1], gateway.id, 'B'), + flow(flowIds[2], gateway.id, 'C'), + ], + ); + +// A task leaving through a fork `gateway`, or directly when there is none, +// for B and C on conditions and for D otherwise. +const split = ( + gateway?: FlowElement, + task: Record = {}, +): BpmnProcess => { + const from = gateway?.id ?? 'A'; + return process( + [ + { kind: 'serviceTask', id: 'A', ...BUILTIN, ...task } as FlowElement, + ...tasks('B', 'C', 'D'), + ...(gateway === undefined ? [] : [gateway]), + ], + [ + ...(gateway === undefined ? [] : [flow('f0', 'A', from)]), + { ...flow('f1', from, 'B'), conditionExpression: '${b}' }, + { ...flow('f2', from, 'C'), conditionExpression: '${c}' }, + flow('f3', from, 'D'), + ], + ); +}; +const BUILTIN = { binding: { kind: 'expression', expression: '${run}' } }; +const CLASS_TASK = { binding: { kind: 'class', className: 'Route' } }; +const DELEGATE_TASK = { + binding: { kind: 'delegateExpression', expression: '${route}' }, +}; +const MI = { cardinality: '3' }; +const MI_AFTER = { cardinality: '3', asyncAfter: true }; + +describe('normalizeIr switches', () => { + const ordered = process(tasks('A', 'B'), [ + flow('f1', 'A', 'B'), + flow('f2', 'B', 'A'), + ]); + + it.each<[string, BpmnProcess, BpmnProcess, NormalizationName[]]>([ + [ + 'a named and an unnamed gateway', + fork({ kind: 'parallelGateway', id: 'G', name: 'Split' }, [ + 'f1', + 'f2', + 'f3', + ]), + fork({ kind: 'parallelGateway', id: 'G' }, ['f1', 'f2', 'f3']), + ['gateway-names'], + ], + [ + 'an extra settings-free merge in front of a task', + process( + [...tasks('A', 'B'), { kind: 'exclusiveGateway', id: 'M' }], + [flow('f1', 'A', 'M'), flow('f2', 'M', 'B')], + ), + process(tasks('A', 'B'), [flow('f1', 'A', 'B')]), + ['pass-through-gateways'], + ], + [ + 'the same elements and flows in reversed order', + ordered, + process(tasks('B', 'A'), [flow('f2', 'B', 'A'), flow('f1', 'A', 'B')]), + ['element-order'], + ], + [ + 'a hand-named and a synthesized split', + fork({ kind: 'exclusiveGateway', id: 'Decide' }, ['f1', 'f2', 'f3']), + fork({ kind: 'exclusiveGateway', id: 'Gateway_p_0_split' }, [ + 'f1', + 'f2', + 'f3', + ]), + ['synthesized-ids'], + ], + [ + 'a hand-named and a synthesized gateway with a default flow inside a block', + nested('Decide', 'ToC'), + nested('Gateway_p_0_split', 'Flow_Gateway_p_0_split_default'), + ['synthesized-ids'], + ], + [ + 'several flows entering a task against an exclusive merge in front of it', + process( + [...tasks('A', 'B', 'C'), { kind: 'exclusiveGateway', id: 'M' }], + [flow('f1', 'A', 'M'), flow('f2', 'B', 'M'), flow('f3', 'M', 'C')], + ), + process(tasks('A', 'B', 'C'), [ + flow('f1', 'A', 'C'), + flow('f2', 'B', 'C'), + ]), + ['uncontrolled-flow'], + ], + [ + 'two unconditioned flows leaving a task against a parallel fork after it', + fork({ kind: 'parallelGateway', id: 'G' }, ['f1', 'f2', 'f3']), + process(tasks('A', 'B', 'C'), [ + flow('f2', 'A', 'B'), + flow('f3', 'A', 'C'), + ]), + ['uncontrolled-flow'], + ], + [ + 'conditioned flows and a default leaving a task against an inclusive fork after it', + split({ kind: 'inclusiveGateway', id: 'G', defaultFlowId: 'f3' }), + split(undefined, { defaultFlowId: 'f3' }), + ['uncontrolled-flow'], + ], + [ + 'a single-route parallel gateway after a task', + process( + [...tasks('A', 'B'), { kind: 'parallelGateway', id: 'G' }], + [flow('f1', 'A', 'G'), flow('f2', 'G', 'B')], + ), + process(tasks('A', 'B'), [flow('f1', 'A', 'B')]), + ['pass-through-gateways'], + ], + [ + 'a named parallel fork against a parallel split stays apart without gateway names', + fork({ kind: 'parallelGateway', id: 'G', name: 'Both' }, [ + 'f1', + 'f2', + 'f3', + ]), + process(tasks('A', 'B', 'C'), [ + flow('f2', 'A', 'B'), + flow('f3', 'A', 'C'), + ]), + ['gateway-names', 'uncontrolled-flow'], + ], + ])( + '%s: only its own normalizations make the difference vanish', + (_, a, b, names) => { + expect(normalizeIr(a)).toEqual(normalizeIr(b)); + for (const n of NORMALIZATIONS) { + const off = new Set([n]); + const equal = + JSON.stringify(normalizeIr(a, off)) === + JSON.stringify(normalizeIr(b, off)); + expect([n, equal]).toEqual([n, !names.includes(n)]); + } + }, + ); + + // Each row breaks one condition under which Operaton leaves the task the way + // the fork routes, so dropping that one guard turns its row red. + it.each<[string, BpmnProcess, BpmnProcess]>([ + [ + 'a task continuing after its job', + split({ kind: 'inclusiveGateway', id: 'G' }, { asyncAfter: true }), + split(undefined, { asyncAfter: true }), + ], + [ + 'a loop continuing after its job', + split({ kind: 'inclusiveGateway', id: 'G' }, { loop: MI_AFTER }), + split(undefined, { loop: MI_AFTER }), + ], + [ + 'a loop with a default flow', + split( + { kind: 'inclusiveGateway', id: 'G', defaultFlowId: 'f3' }, + { loop: MI }, + ), + split(undefined, { loop: MI, defaultFlowId: 'f3' }), + ], + [ + 'a delegate class, which may pick its flow itself', + split({ kind: 'inclusiveGateway', id: 'G' }, CLASS_TASK), + split(undefined, CLASS_TASK), + ], + [ + 'a delegate expression, which may pick its flow itself', + split({ kind: 'inclusiveGateway', id: 'G' }, DELEGATE_TASK), + split(undefined, DELEGATE_TASK), + ], + [ + 'a parallel fork ignoring the conditions the task would evaluate', + split({ kind: 'parallelGateway', id: 'G' }), + split(), + ], + [ + 'a fork that also joins', + process( + [...tasks('A', 'B', 'C', 'D'), { kind: 'parallelGateway', id: 'G' }], + [ + flow('f1', 'A', 'G'), + flow('f0', 'D', 'G'), + flow('f2', 'G', 'B'), + flow('f3', 'G', 'C'), + ], + ), + process(tasks('A', 'B', 'C', 'D'), [ + flow('f2', 'A', 'B'), + flow('f3', 'A', 'C'), + flow('f4', 'D', 'B'), + flow('f5', 'D', 'C'), + ]), + ], + [ + 'a task whose only flow carries a condition', + process( + [...tasks('A', 'B', 'C'), { kind: 'parallelGateway', id: 'G' }], + [ + { ...flow('f1', 'A', 'G'), conditionExpression: '${go}' }, + flow('f2', 'G', 'B'), + flow('f3', 'G', 'C'), + ], + ), + process(tasks('A', 'B', 'C'), [ + flow('f2', 'A', 'B'), + flow('f3', 'A', 'C'), + ]), + ], + [ + 'a task with a second flow of its own', + process( + [...tasks('A', 'B', 'C', 'D'), { kind: 'parallelGateway', id: 'G' }], + [ + flow('f1', 'A', 'G'), + flow('f4', 'A', 'D'), + flow('f2', 'G', 'B'), + flow('f3', 'G', 'C'), + ], + ), + process(tasks('A', 'B', 'C', 'D'), [ + flow('f2', 'A', 'B'), + flow('f3', 'A', 'C'), + flow('f4', 'A', 'D'), + ]), + ], + ])( + 'a fork after %s stays apart from flows leaving the task directly', + (_, withFork, direct) => { + expect(normalizeIr(withFork)).not.toEqual(normalizeIr(direct)); + }, + ); + + it('a non-default history time to live stays unequal to none under every switch', () => { + const a = { ...ordered, historyTimeToLive: 'P7D' }; + for (const n of [...NORMALIZATIONS, undefined]) { + const off = new Set(n === undefined ? [] : [n]); + expect([n, normalizeIr(a, off)]).not.toEqual([ + n, + normalizeIr(ordered, off), + ]); + } + }); + + it.each(['exclusiveGateway', 'inclusiveGateway'] as const)( + 'a conditioned single route out of a %s is kept', + (kind) => { + const build = (conditionExpression?: string) => + process( + [...tasks('A', 'B'), { kind, id: 'M' }], + [ + flow('f1', 'A', 'M'), + { ...flow('f2', 'M', 'B'), conditionExpression }, + ], + ); + expect(normalizeIr(build('${x}'))).not.toEqual(normalizeIr(build())); + }, + ); +}); diff --git a/tests/helpers/normalize-ir.ts b/tests/helpers/normalize-ir.ts index 299b1fe..cf6f466 100644 --- a/tests/helpers/normalize-ir.ts +++ b/tests/helpers/normalize-ir.ts @@ -2,6 +2,9 @@ // 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. +// +// Loaded outside vitest through Node's type stripping: import only the +// workspace packages and sibling files, with `.ts` extensions. import { ENGINE_KEYS } from '@bpmn-script/language'; import { @@ -10,49 +13,110 @@ import { type EventDefinition, type FlowContainer, type FlowElement, + type Named, type SequenceFlow, } from '@bpmn-script/transform'; -export function normalizeIr(ir: BpmnProcess): BpmnProcess { - return normalizeContainer(ir); +/** One per normalization the comparison helper can report. */ +export const NORMALIZATIONS = [ + 'synthesized-ids', + 'gateway-names', + 'pass-through-gateways', + 'element-order', + 'expression-parse-trees', + 'default-history-time-to-live', + 'implicit-end', + 'uncontrolled-flow', + 'unconditioned-last-route', + 'nested-forks', +] as const; + +export type NormalizationName = (typeof NORMALIZATIONS)[number]; + +/** + * `off` switches normalizations off. `expression-parse-trees`, + * `default-history-time-to-live` and `implicit-end` are applied by the + * caller, never here. + */ +export function normalizeIr( + ir: BpmnProcess, + off: ReadonlySet = new Set(), +): BpmnProcess { + return normalizeContainer(ir, off); +} + +export function hasOnly(x: object, keys: readonly string[]): boolean { + return Object.entries(x).every( + ([k, v]) => v === undefined || keys.includes(k), + ); +} + +export function isPlainFlow(sf: SequenceFlow): boolean { + return hasOnly(sf, ['id', 'sourceRef', 'targetRef']); } -function normalizeContainer(container: T): T { +function normalizeContainer( + container: T, + off: ReadonlySet, +): T { // Before re-keying, so both halves have the same element set. - const inlined = inlinePassThroughJoins(container); + const joined = inlinePassThroughJoins(container, off); + // Nested forks merge first, while the outer fork is still a gateway. + const forks = off.has('nested-forks') + ? joined + : mergeNestedForks(joined, off); + const split = off.has('uncontrolled-flow') + ? forks + : inlineImplicitSplits(forks, off); + const inlined = off.has('unconditioned-last-route') + ? split + : withLastRouteDefaults(split); - const gatewayIdMap = buildCanonicalIds(inlined, (fe) => - gatewaySignature(fe, inlined), - ); - const handlerIdMap = buildCanonicalIds(inlined, eventSubProcessSignature); - const boundaryIdMap = buildCanonicalIds(inlined, boundarySignature); + const keyed = ( + signatureOf: (fe: FlowElement) => string | undefined, + ): Map => + off.has('synthesized-ids') + ? new Map() + : buildCanonicalIds(inlined, signatureOf); + const gatewayIdMap = keyed((fe) => gatewaySignature(fe, inlined)); + const handlerIdMap = keyed(eventSubProcessSignature); + const boundaryIdMap = keyed(boundarySignature); + const rekey = !off.has('synthesized-ids'); const canonicalId = (id: string): string => gatewayIdMap.get(id) ?? boundaryIdMap.get(id) ?? id; - const flowElements: FlowElement[] = inlined.flowElements - .map((fe) => { - // A `triggeredByEvent` one has no surface id. Its `defaultFlowId` names - // a flow of this container, not of its body. - if (fe.kind === 'subProcess') { - const normalized = { ...normalizeContainer(fe), ...reKeyedDefault(fe) }; - return fe.triggeredByEvent === true - ? { ...normalized, id: handlerIdMap.get(fe.id) ?? fe.id } - : normalized; - } + const flowElements: FlowElement[] = inlined.flowElements.map((fe) => { + // 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, off), + ...reKeyedDefault(fe), + }; + return fe.triggeredByEvent === true + ? { ...normalized, id: handlerIdMap.get(fe.id) ?? fe.id } + : normalized; + } - if (fe.kind === 'boundaryEvent') { - return { ...fe, id: canonicalId(fe.id) }; - } + if (fe.kind === 'boundaryEvent') { + return { ...fe, id: canonicalId(fe.id) }; + } - if (!isGateway(fe)) return { ...fe, ...reKeyedDefault(fe) }; - const id = canonicalId(fe.id); + if (!isGateway(fe)) return { ...fe, ...reKeyedDefault(fe) }; + const id = canonicalId(fe.id); - // The structured syntax has no slot for a gateway label. - const { name: _name, ...withoutName } = fe; - return { ...withoutName, id, ...reKeyedDefault(fe), ...routeOrder(fe) }; - }) - .sort((a, b) => a.id.localeCompare(b.id)); + // The structured syntax has no slot for a gateway label. + const { name: _name, ...withoutName } = fe; + return { + ...(off.has('gateway-names') ? fe : withoutName), + id, + ...reKeyedDefault(fe), + ...routeOrder(fe), + }; + }); + if (!off.has('element-order')) + flowElements.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 @@ -74,13 +138,17 @@ function normalizeContainer(container: T): T { const target = inlined.sequenceFlows.find((sf) => sf.id === declared); return { defaultFlowId: - target === undefined ? declared : normalizeFlow(target, canonicalId).id, + target === undefined + ? declared + : normalizeFlow(target, canonicalId, rekey).id, }; } - const sequenceFlows: SequenceFlow[] = inlined.sequenceFlows - .map((sf) => normalizeFlow(sf, canonicalId)) - .sort( + const sequenceFlows: SequenceFlow[] = inlined.sequenceFlows.map((sf) => + normalizeFlow(sf, canonicalId, rekey), + ); + if (!off.has('element-order')) + sequenceFlows.sort( (a, b) => a.id.localeCompare(b.id) || (a.conditionExpression ?? '').localeCompare( @@ -95,24 +163,31 @@ function normalizeContainer(container: T): T { } as T; } +// A gateway with one outgoing flow is only transparent when that flow is +// unconditioned: a condition decides whether the token moves at all. // 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 { +// and an event-based one never is, since it waits for its event. +// A merge of several flows that leads to a node other than a gateway is the +// implicit merge of `uncontrolled-flow`; any other is a pass-through gateway. +function inlinePassThroughJoins( + ir: FlowContainer, + off: ReadonlySet, +): 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) || fe.kind === 'eventBasedGateway') continue; - if (ENGINE_KEYS.some((key) => key in fe) || fe.documentation !== undefined) - continue; + if (!isInlinable(fe, off)) 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 === 0) continue; - if (incoming.length === 1 || fe.kind === 'exclusiveGateway') { + if (outgoing[0].conditionExpression !== undefined) continue; + if (incoming.length === 1 && !off.has('pass-through-gateways')) { + successorOf.set(fe.id, outgoing[0].targetRef); + } else if (incoming.length > 1 && fe.kind === 'exclusiveGateway') { successorOf.set(fe.id, outgoing[0].targetRef); } } @@ -135,11 +210,20 @@ function inlinePassThroughJoins(ir: FlowContainer): FlowContainer { kept = false; for (const [id] of successorOf) { const end = resolve(id); - const kind = end === undefined ? undefined : byId.get(end)?.kind; + const target = end === undefined ? undefined : byId.get(end); const merges = ir.sequenceFlows.filter((sf) => sf.targetRef === id); const synchronizes = - kind === 'parallelGateway' || kind === 'inclusiveGateway'; - if (end === undefined || (merges.length > 1 && synchronizes)) { + target?.kind === 'parallelGateway' || + target?.kind === 'inclusiveGateway'; + const switchedOff = off.has( + target !== undefined && isGateway(target) + ? 'pass-through-gateways' + : 'uncontrolled-flow', + ); + if ( + end === undefined || + (merges.length > 1 && (synchronizes || switchedOff)) + ) { successorOf.delete(id); kept = true; } @@ -159,6 +243,162 @@ function inlinePassThroughJoins(ir: FlowContainer): FlowContainer { return { ...ir, flowElements, sequenceFlows }; } +// Operaton leaves any node but a gateway through every outgoing flow whose +// condition holds or that has none, takes the node's `default` only when no +// other is taken, and refuses to continue when none is +// (`BpmnActivityBehavior.performOutgoingBehavior`). That is what an inclusive +// fork, or a parallel fork with unconditioned flows, does when it sits behind +// the node's only flow. The fork stays where the node routes differently: +// - `asyncAfter` makes each flow the node takes its own job: one job per +// branch, where the node in front of a fork makes one. +// - a `class` or `delegateExpression` may be an `ActivityBehavior` that picks +// its outgoing flow by id itself. +// - a multi-instance node's flows leave its body, so the node itself finds +// no flow, looks for the `default` it names among its own, and fails. +// Where no condition holds and there is no default, both refuse, though the +// engine message then names the fork instead of the node. +function inlineImplicitSplits( + ir: FlowContainer, + off: ReadonlySet, +): FlowContainer { + const byId = new Map(ir.flowElements.map((fe) => [fe.id, fe])); + const hostOf = new Map(); + const defaultOf = new Map(); + for (const fork of ir.flowElements) { + if (fork.kind !== 'parallelGateway' && fork.kind !== 'inclusiveGateway') + continue; + if (!isInlinable(fork, off)) continue; + const incoming = ir.sequenceFlows.filter((sf) => sf.targetRef === fork.id); + const outgoing = ir.sequenceFlows.filter((sf) => sf.sourceRef === fork.id); + if (incoming.length !== 1 || !isPlainFlow(incoming[0])) continue; + if (outgoing.length < 2) continue; + if ( + fork.kind === 'parallelGateway' && + outgoing.some((sf) => sf.conditionExpression !== undefined) + ) + continue; + const host = byId.get(incoming[0].sourceRef); + if (host === undefined || isGateway(host)) continue; + if (ir.sequenceFlows.filter((sf) => sf.sourceRef === host.id).length > 1) + continue; + if (host.asyncAfter === true) continue; + if ('loop' in host && host.loop !== undefined) { + if (host.loop.asyncAfter === true) continue; + if (fork.kind === 'inclusiveGateway' && fork.defaultFlowId !== undefined) + continue; + } + if ( + 'binding' in host && + (host.binding?.kind === 'class' || + host.binding?.kind === 'delegateExpression') + ) + continue; + hostOf.set(fork.id, host.id); + if (fork.kind === 'inclusiveGateway' && fork.defaultFlowId !== undefined) + defaultOf.set(host.id, fork.defaultFlowId); + } + if (hostOf.size === 0) return ir; + + const flowElements = ir.flowElements + .filter((fe) => !hostOf.has(fe.id)) + .map((fe) => + defaultOf.has(fe.id) + ? ({ ...fe, defaultFlowId: defaultOf.get(fe.id) } as FlowElement) + : fe, + ); + const sequenceFlows = ir.sequenceFlows + .filter((sf) => !hostOf.has(sf.targetRef)) + .map((sf) => ({ + ...sf, + sourceRef: hostOf.get(sf.sourceRef) ?? sf.sourceRef, + })); + return { ...ir, flowElements, sequenceFlows }; +} + +// The printer gives each join that takes only some of a parallel fork's routes +// a settings-free fork of its own behind that fork. Every route still starts +// at once, so the inner fork's routes are the outer one's. An outer fork with +// `asyncAfter` makes a job per outgoing flow, so it keeps its inner forks. +function mergeNestedForks( + ir: FlowContainer, + off: ReadonlySet, +): FlowContainer { + const byId = new Map(ir.flowElements.map((fe) => [fe.id, fe])); + const outerOf = new Map(); + for (const fork of ir.flowElements) { + if (fork.kind !== 'parallelGateway' || !isInlinable(fork, off)) continue; + const incoming = ir.sequenceFlows.filter((sf) => sf.targetRef === fork.id); + const outgoing = ir.sequenceFlows.filter((sf) => sf.sourceRef === fork.id); + if (incoming.length !== 1 || !isPlainFlow(incoming[0])) continue; + if (outgoing.length < 2) continue; + if (outgoing.some((sf) => sf.conditionExpression !== undefined)) continue; + const outer = byId.get(incoming[0].sourceRef); + if (outer?.kind !== 'parallelGateway' || outer.asyncAfter === true) + continue; + outerOf.set(fork.id, outer.id); + } + // A fork nested in a nested fork resolves to the outermost; a cycle of + // forks nothing enters is kept whole. + const resolve = (id: string): string | undefined => { + const seen = new Set(); + let at = id; + while (outerOf.has(at)) { + if (seen.has(at)) return undefined; + seen.add(at); + at = outerOf.get(at)!; + } + return at; + }; + // Decided before deleting: a deletion would let the rest of a cycle resolve. + const cyclic = [...outerOf.keys()].filter((id) => resolve(id) === undefined); + for (const id of cyclic) outerOf.delete(id); + if (outerOf.size === 0) return ir; + + const flowElements = ir.flowElements.filter((fe) => !outerOf.has(fe.id)); + const sequenceFlows = ir.sequenceFlows + .filter((sf) => !outerOf.has(sf.targetRef)) + .map((sf) => + outerOf.has(sf.sourceRef) + ? { ...sf, sourceRef: resolve(sf.sourceRef)! } + : sf, + ); + return { ...ir, flowElements, sequenceFlows }; +} + +// Operaton's exclusive gateway skips its `default` and takes the first other +// flow in document order that has no condition or one that holds +// (`ExclusiveGatewayActivityBehavior`). An unconditioned last route behind +// only conditioned ones is therefore taken exactly when a default would be. +// An inclusive gateway always takes an unconditioned flow, so there it is no +// default. +function withLastRouteDefaults(ir: FlowContainer): FlowContainer { + const flowElements = ir.flowElements.map((fe): FlowElement => { + if (fe.kind !== 'exclusiveGateway' || fe.defaultFlowId !== undefined) + return fe; + const routes = ir.sequenceFlows.filter((sf) => sf.sourceRef === fe.id); + const fallback = routes.findIndex( + (sf) => sf.conditionExpression === undefined, + ); + if (routes.length < 2 || fallback !== routes.length - 1) return fe; + return { ...fe, defaultFlowId: routes[fallback].id }; + }); + return { ...ir, flowElements }; +} + +// One carrying a job setting or documentation stays, or that would vanish from +// both sides of the comparison; so does a named one while `gateway-names` is +// off, or a dropped name would go unreported. +function isInlinable( + gateway: FlowElement & Named, + off: ReadonlySet, +): boolean { + return ( + !ENGINE_KEYS.some((key) => key in gateway) && + gateway.documentation === undefined && + (gateway.name === undefined || !off.has('gateway-names')) + ); +} + // `undefined` skips the element; ties get a positional `#n` suffix. function buildCanonicalIds( ir: FlowContainer, @@ -268,8 +508,14 @@ function definitionPayloadKey(def: EventDefinition | undefined): string { function normalizeFlow( sf: SequenceFlow, canonicalId: (id: string) => string, + rekey: boolean, ): SequenceFlow { const sourceRef = canonicalId(sf.sourceRef); const targetRef = canonicalId(sf.targetRef); - return { ...sf, id: `Flow_${sourceRef}_${targetRef}`, sourceRef, targetRef }; + return { + ...sf, + id: rekey ? `Flow_${sourceRef}_${targetRef}` : sf.id, + sourceRef, + targetRef, + }; } diff --git a/tests/import-contract.round-trip.test.ts b/tests/import-contract.round-trip.test.ts index 2f1cfad..f4f3a2e 100644 --- a/tests/import-contract.round-trip.test.ts +++ b/tests/import-contract.round-trip.test.ts @@ -6,10 +6,8 @@ import { describe, it, expect } from 'vitest'; import { astToIr, - COMPENSATION_BOUNDARY_DETAIL, - CONNECTOR_CONSTRUCT, + irToXml, dataConstructDropMessage, - IS_FOR_COMPENSATION_DETAIL, loopDroppedMessage, manualTaskMessage, xmlToIr, @@ -55,11 +53,11 @@ const FIXTURE = bpmnDoc( ); const IMPORTED_FLOW_NOTE = - '(this tool imports the executable flow and the engine settings on its ' + - 'steps, and nothing declared or drawn beside it).'; + 'note: BPMNscript imports the executable flow and the engine settings on ' + + 'its steps, and nothing declared or drawn beside it'; const dataDropped = (tag: string, id: string) => - dataConstructDropMessage(`bpmn:${tag} '${id}'`); + dataConstructDropMessage(`'bpmn:${tag}' element '${id}'`); const EXPECTED_WARNINGS: ImportWarning[] = [ { @@ -91,15 +89,14 @@ const EXPECTED_WARNINGS: ImportWarning[] = [ elementId: 'Flow_Decide_SignOff', category: 'extensionAttribute', message: - "The 'operaton:resource' setting on 'Flow_Decide_SignOff' 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.', + "setting 'operaton:resource' on 'Flow_Decide_SignOff' was not imported\n" + + "note: it only takes effect alongside a 'language' attribute\n" + + 'note: on its own the condition runs as the expression written in the body', }, { elementId: 'G3', category: 'unmappedConstruct', - message: `A bpmn:globalScriptTask 'G3' root element was not imported ${IMPORTED_FLOW_NOTE}`, + message: `root 'bpmn:globalScriptTask' element 'G3' was not imported\n${IMPORTED_FLOW_NOTE}`, }, ]; @@ -112,6 +109,47 @@ it('a document holding every construct the import converts warns exactly per the expect(normalizeIr(astToIr(await parseToAst(dsl)))).toEqual(normalizeIr(ir)); }); +// Labels equal to the id in words are the ones an import may wrongly treat as +// derivable; the export derives a label only for tasks and sub-processes. +const LABELLED = bpmnDoc( + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' PT1H\n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' ', +); + +it('every label written on a surface that carries one survives XML -> script -> XML, also when it equals the id in words', async () => { + const names = (xml: string) => + Object.fromEntries( + [...xml.matchAll(/ [id, name], + ), + ); + const { ir, warnings } = await xmlToIr(LABELLED); + expect(warnings).toEqual([]); + const rebuilt = await irToXml(astToIr(await parseToAst(printDsl(ir)))); + const kept = names(rebuilt); + expect( + Object.fromEntries( + Object.keys(names(LABELLED)).map((id) => [id, kept[id]]), + ), + ).toEqual(names(LABELLED)); +}); + async function refusalError( xml: string, errorClass: abstract new (...args: never[]) => Error, @@ -149,6 +187,21 @@ const COMPENSATED_ROOM = const association = (target: string) => `\n `; +// Notes several refusals share. +const CONNECTOR_NOTES = [ + "note: the Connect plugin runs it in place of whatever 'operaton:class', 'expression', 'delegateExpression' or 'type' names beside it", + 'note: an engine without the plugin runs that binding instead of it, so the same file has two possible executions', + 'note: the 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 a decision reference on a business rule task', + "note: their attributes are 'operaton:class' (or the deprecated 'camunda:class' alias), 'operaton:expression', 'operaton:delegateExpression', 'operaton:type=\"external\"' with 'operaton:topic', 'operaton:type=\"mail\"' or 'operaton:type=\"shell\"' with their 'operaton:field' children, and 'operaton:decisionRef'", +]; +const COMPENSATION_BOUNDARY_REFUSAL = [ + "element 'CompensationBoundary' cannot be imported: a compensation boundary event is not imported", + "note: BPMN attaches compensation through 'isForCompensation' and a 'bpmn:association' on the activity being compensated, not a boundary event", + "help: wrap the steps in their own subprocess and target it with 'on compensation'", +].join('\n'); +const SUPPORTED_ELEMENTS_NOTE = + 'note: BPMNscript imports start and 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'; + describe('every construct the import refuses names itself in its own message', () => { it.each([ [ @@ -163,7 +216,12 @@ describe('every construct the import refuses names itself in its own message', ( ' ', ), UnsupportedConditionExpressionError, - ['language="groovy"'], + [ + "condition on 'Flow_S_T' cannot be imported: it declares 'language=\"groovy\"'", + 'note: Operaton runs it in a script engine rather than evaluating it as UEL', + "note: BPMNscript writes a condition as a 'tFormalExpression', which Operaton evaluates as UEL", + 'note: a condition of another type, or one Operaton hands to a script engine, has no spelling in BPMNscript', + ].join('\n'), ], [ 'a script task with a deployment resource names the resource', @@ -173,7 +231,10 @@ describe('every construct the import refuses names itself in its own message', ( 'operaton:resource="deployment://check.groovy">1 + 1', ), UnsupportedExtensionFormError, - ['names an external resource ("deployment://check.groovy")', "'Compute'"], + [ + "the Operaton extension content on 'Compute' cannot be imported: the script on 'Compute' names an external resource ('deployment://check.groovy')", + "note: 'ScriptUtil.getScript' runs the resource in place of the body, and only an inline body can be written here", + ].join('\n'), ], [ 'a connector as the only implementation names the connector', @@ -182,7 +243,10 @@ describe('every construct the import refuses names itself in its own message', ( `${CONNECTOR}`, ), UnsupportedServiceTaskFormError, - [CONNECTOR_CONSTRUCT], + [ + "service task 'Notify' uses an unsupported execution form: an 'operaton:connector' element", + ...CONNECTOR_NOTES, + ].join('\n'), ], [ 'a connector beside a class names the connector, not the class', @@ -191,7 +255,10 @@ describe('every construct the import refuses names itself in its own message', ( `${CONNECTOR}`, ), UnsupportedServiceTaskFormError, - [CONNECTOR_CONSTRUCT], + [ + "service task 'NotifyBeta' uses an unsupported execution form: an 'operaton:connector' element", + ...CONNECTOR_NOTES, + ].join('\n'), ], [ 'an unpaired isForCompensation activity falls back to the general wording', @@ -200,13 +267,17 @@ describe('every construct the import refuses names itself in its own message', ( '', ), UnsupportedEventFeatureError, - [IS_FOR_COMPENSATION_DETAIL], + [ + "element 'CancelReservation' cannot be imported: 'isForCompensation=\"true\"' marks this activity as excluded from normal flow", + "note: that is BPMN's boundary-event compensation-handler pattern, which BPMNscript cannot import", + "help: wrap the steps in their own subprocess and target it with 'on compensation'", + ].join('\n'), ], [ 'an unpaired compensation boundary event falls back to the general wording', oneStep('ReserveRoom', COMPENSATED_ROOM), UnsupportedEventFeatureError, - [COMPENSATION_BOUNDARY_DETAIL], + COMPENSATION_BOUNDARY_REFUSAL, ], [ 'a paired compensation triple names the compensated activity, the boundary, and the handler', @@ -217,7 +288,17 @@ describe('every construct the import refuses names itself in its own message', ( association('CancelReservation'), ), UnsupportedEventFeatureError, - ['ReserveRoom', 'CompensationBoundary', 'CancelReservation'], + [ + "element 'CompensationBoundary' cannot be imported: boundary event 'CompensationBoundary' compensates 'ReserveRoom' through the handler 'CancelReservation' its 'bpmn:association' targets", + 'note: a compensation boundary event is not imported', + 'help: write it by hand instead', + ' subprocess Compensated_ReserveRoom {', + ' service ReserveRoom(class: "com.example.Reserve")', + ' on compensation {', + ' user CancelReservation', + ' }', + ' }', + ].join('\n'), ], [ 'a compensation association pointing at a non-handler falls back to the general wording', @@ -227,7 +308,7 @@ describe('every construct the import refuses names itself in its own message', ( association('NotAHandler'), ), UnsupportedEventFeatureError, - [COMPENSATION_BOUNDARY_DETAIL], + COMPENSATION_BOUNDARY_REFUSAL, ], [ 'an activityRef on a compensation definition names the attribute', @@ -242,10 +323,9 @@ describe('every construct the import refuses names itself in its own message', ( ), UnsupportedEventFeatureError, [ - 'a compensation definition targets one activity by reference ' + - '(activityRef="T"); this tool always addresses the enclosing ' + - 'scope and cannot target a single activity', - ], + "element 'ThrowUndo' cannot be imported: a compensation definition targets one activity by reference ('activityRef=\"T\"')", + 'note: BPMNscript always addresses the enclosing scope and cannot target a single activity', + ].join('\n'), ], [ 'waitForCompletion="false" names the attribute', @@ -258,10 +338,9 @@ describe('every construct the import refuses names itself in its own message', ( ), UnsupportedEventFeatureError, [ - 'a compensation definition sets waitForCompletion="false"; this ' + - 'tool only imports the default (wait for the compensation to ' + - 'complete) behavior', - ], + "element 'ThrowUndo' cannot be imported: a compensation definition sets 'waitForCompletion=\"false\"'", + 'note: BPMNscript only imports the default behaviour, which waits for the compensation to complete', + ].join('\n'), ], [ 'bpmn:adHocSubProcess stays refused on engine evidence', @@ -270,18 +349,17 @@ describe('every construct the import refuses names itself in its own message', ( '', ), UnsupportedElementError, - ['bpmn:AdHocSubProcess'], + "element 'T' of the kind 'bpmn:AdHocSubProcess' cannot be imported\n" + + SUPPORTED_ELEMENTS_NOTE, ], [ 'bpmn:complexGateway stays refused on engine evidence', oneStep('T', ''), UnsupportedElementError, - ['bpmn:ComplexGateway'], + "element 'T' of the kind 'bpmn:ComplexGateway' cannot be imported\n" + + SUPPORTED_ELEMENTS_NOTE, ], - ] as const)('%s', async (_title, xml, errorClass, needles) => { - const error = await refusalError(xml, errorClass); - for (const needle of needles) { - expect(error.message).toContain(needle); - } + ] as const)('%s', async (_title, xml, errorClass, message) => { + expect((await refusalError(xml, errorClass)).message).toBe(message); }); }); diff --git a/tests/invoice-receipt.import.test.ts b/tests/invoice-receipt.import.test.ts index 695c6b1..780a7df 100644 --- a/tests/invoice-receipt.import.test.ts +++ b/tests/invoice-receipt.import.test.ts @@ -11,6 +11,7 @@ import { xmlToIr, astToIr, irToDsl, irToXml } from '@bpmn-script/transform'; import { realNodeReachability } from './helpers/real-node-reachability.js'; import { parseToAst, validate } from './helpers/pipeline.js'; +import { compareModels } from './helpers/model-equivalence.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const fixture = (name: string) => @@ -43,7 +44,7 @@ const PRINTED = [ ' in invoiceNumber', ' out clarified', ' }', - ' }', + ' } else fail', ' } 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()}")', @@ -53,11 +54,49 @@ const PRINTED = [ '', ].join('\n'); +const PRINTED_NO_FALLBACK = [ + 'process invoice(label: "Invoice Receipt", versionTag: "V1.0", historyTimeToLive: "30") {', + ' 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")', + ' 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) {', + ' service ServiceTask_06mdb3v(label: "Notify Creditor", class: "org.operaton.bpm.example.invoice.service.NotifyCreditorService")', + ' 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")', + ' } else if (!approved) {', + ' } else fail', + ' call reviewInvoice(process: "ReviewInvoice") {', + ' in invoiceDocument', + ' in creditor', + ' in amount', + ' in invoiceCategory', + ' in invoiceNumber', + ' out clarified', + ' }', + ' if (!clarified) {', + ' end invoiceNotProcessed(label: "Invoice not\\nprocessed")', + ' } else if (clarified) {', + ' } else fail', + ' goto approveInvoice', + '}', + '', +].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 () => { + it('imports with the pool, lane and dropped-extra warnings, prints one clean structured script that prints again unchanged, and re-desugars to the same reachability', async () => { const { ir: imported, warnings: importWarnings } = await xmlToIr(FIXTURE); expect(categories(importWarnings)).toEqual([ { elementId: 'Accountant', category: 'lane' }, @@ -82,40 +121,38 @@ describe('the Operaton invoice example', () => { 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(), + realNodeReachability(imported), ); - expect((await xmlToIr(await irToXml(reDesugared))).warnings).toEqual([]); + const rebuilt = await xmlToIr(await irToXml(reDesugared)); + expect(rebuilt.warnings).toEqual([]); + expect(irToDsl(rebuilt.ir).source).toEqual(dsl); }); - 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); - }, - ); + it("the no-fallback version closes both splits with 'else fail', prints the review split as a guard clause back into approval, prints again unchanged, and rebuilds the model unchanged", async () => { + const { ir: imported } = await xmlToIr( + fixture('invoice-receipt-no-fallback.bpmn'), + ); + const { source: dsl, warnings } = irToDsl(imported); + // The approve route is a back edge: its reach runs on through the printed + // `if (approved)` chain, whose end is no merge for this split. + expect(dsl).toEqual(PRINTED_NO_FALLBACK); + expect(categories(warnings)).toEqual([ + { elementId: 'invoice_approved', category: 'label' }, + { elementId: 'reviewSuccessful_gw', category: 'label' }, + ]); + expect((await validate(dsl)).diagnostics).toEqual([]); + + const rebuilt = await xmlToIr( + await irToXml(astToIr(await parseToAst(dsl))), + ); + const { level, differences } = compareModels(imported, rebuilt.ir); + expect({ level, differences }).toEqual({ level: 'same', differences: [] }); + expect(irToDsl(rebuilt.ir).source).toEqual(dsl); + }); }); diff --git a/tests/package.json b/tests/package.json index 2b297cc..e878d25 100644 --- a/tests/package.json +++ b/tests/package.json @@ -1,7 +1,7 @@ { "name": "tests", "description": "Integration and E2E tests for BPMNscript", - "version": "0.0.1", + "version": "0.1.0", "license": "Apache-2.0", "repository": { "type": "git", @@ -17,14 +17,14 @@ "scripts": { "typecheck": "tsc -b tsconfig.json", "test": "vitest run", - "clean": "shx rm -fr *.tsbuildinfo out" + "clean": "node -e \"for (const f of [...fs.globSync('*.tsbuildinfo'), 'out']) fs.rmSync(f, { recursive: true, force: true })\"" }, "devDependencies": { - "@bpmn-script/language": "0.0.1", - "@bpmn-script/transform": "0.0.1", - "langium": "^4.3.1", - "testcontainers": "^12.1.0", - "vitest": "^4.1.11", + "@bpmn-script/language": "0.1.0", + "@bpmn-script/transform": "0.1.0", + "langium": "^4.4.0", + "testcontainers": "^12.2.1", + "vitest": "^5.0.5", "vscode-languageserver-types": "^3.18.0" } } diff --git a/tests/parse-performance.test.ts b/tests/print-performance.test.ts similarity index 57% rename from tests/parse-performance.test.ts rename to tests/print-performance.test.ts index f654023..95a30a6 100644 --- a/tests/parse-performance.test.ts +++ b/tests/print-performance.test.ts @@ -1,11 +1,10 @@ -// 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 { astToIr, irToDsl, xmlToIr } from '@bpmn-script/transform'; import { parseToAst, printDsl } from './helpers/pipeline.js'; const TASK_COUNT = 1000; +const WHILE_COUNT = 150; +const BUDGET_MS = 60_000; function chainBpmn(taskCount: number): string { const flowElements: string[] = [ @@ -46,7 +45,21 @@ function chainBpmn(taskCount: number): string { ].join('\n'); } +// 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', () => { + // 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. it('imports and prints a 1,000-task straight-line chain in under 3 s', async () => { const xml = chainBpmn(TASK_COUNT); @@ -65,4 +78,29 @@ describe('CFG analysis performance', () => { expect(elapsed).toBeLessThan(3000); await parseToAst(source); }); + + // `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. + 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/review-loop.round-trip.test.ts b/tests/review-loop.round-trip.test.ts index 5c95c43..97a52fd 100644 --- a/tests/review-loop.round-trip.test.ts +++ b/tests/review-loop.round-trip.test.ts @@ -11,8 +11,12 @@ const rt = roundTripFixture('review-loop', { }); it('prints the split inside the loop body, the leaving route as a jump and the staying route inline', () => { - expect(rt.dslPrime).toContain( + expect(rt.dslPrime).toBe( [ + 'process invoice-review {', + ' var approved: any', + ' var clarified: any', + ' start InvoiceReceived', ' do {', ' user ApproveInvoice(assignee: "demo")', ' if (approved) {', @@ -24,6 +28,11 @@ it('prints the split inside the loop body, the leaving route as a jump and the s ' }', ' }', ' } 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")', + '}', '', ].join('\n'), ); diff --git a/tests/round-trip-constructs.test.ts b/tests/round-trip-constructs.test.ts index fb6d4c4..8902f5c 100644 --- a/tests/round-trip-constructs.test.ts +++ b/tests/round-trip-constructs.test.ts @@ -183,7 +183,18 @@ describe('two-pass round trip: printed expression text stays engine-runnable and '', ].join('\n'), ['>${myBean.check()}'], - [' if ("${myBean.check()}") {\n'], + [ + 'process bean-cond {', + ' start S', + ' if ("${myBean.check()}") {', + ' user Approve(assignee: "demo")', + ' } else {', + ' user Reject(assignee: "demo")', + ' }', + ' end E', + '}', + '', + ].join('\n'), ], [ 'a backslash inside a JUEL string literal prints as the doubled escape and compiles back to the same three characters', @@ -196,7 +207,15 @@ describe('two-pass round trip: printed expression text stays engine-runnable and '', ].join('\n'), ['${region == "a\\\\b"}'], - ['if (region == "a\\\\b") {'], + [ + 'process order-fulfilment {', + ' var region: any', + ' if (region == "a\\\\b") {', + ' user A', + ' }', + '}', + '', + ].join('\n'), ], [ 'an out-of-subset raw condition with a quoted method call keeps its inner quotes, not doubled escapes', @@ -209,7 +228,14 @@ describe('two-pass round trip: printed expression text stays engine-runnable and '', ].join('\n'), ['${execution.getVariable("x")}'], - [], + [ + 'process p {', + ' if ("${execution.getVariable(\\"x\\")}") {', + ' user A', + ' }', + '}', + '', + ].join('\n'), ], [ 'a raw operand under a unary operator splices its body instead of nesting a second ${ }', @@ -217,7 +243,15 @@ describe('two-pass round trip: printed expression text stays engine-runnable and '\n', ), ['${!(x)}'], - [], + [ + 'process p {', + ' var x: any', + ' if (!(x)) {', + ' user A', + ' }', + '}', + '', + ].join('\n'), ], [ 'quoted-raw method-call bindings on delegate, expression and assignee keep their inner quotes', @@ -234,7 +268,14 @@ describe('two-pass round trip: printed expression text stays engine-runnable and 'operaton:expression="${bean.m("a")}"', 'operaton:assignee="${who("a")}"', ], - [], + [ + 'process p {', + ' service S(delegate: "${fn(\\"a\\")}")', + ' service T(expression: "${bean.m(\\"a\\")}")', + ' user U(assignee: "${who(\\"a\\")}")', + '}', + '', + ].join('\n'), ], [ 'a bare variable in an io value, a list item or a map entry renders as ${var}, not as its name in text', @@ -265,7 +306,21 @@ describe('two-pass round trip: printed expression text stays engine-runnable and '${true}', '', ], - [], + [ + 'process p {', + ' var notify_sync: any', + ' service A(class: "x") {', + ' input b = "${notify_sync.lines}"', + ' input c = ["${notify_sync}", "${1}", "${-644}", "s"]', + ' input d = { "k": "${notify_sync}", "k2": "${null}", "k3": "${true}" }', + ' output a = "${notify_sync}"', + ' }', + ' call C(process: "x") {', + ' in y = notify_sync', + ' }', + '}', + '', + ].join('\n'), ], [ '#{ } bindings across a listener, tasks and a call activity print verbatim with the # opener kept', @@ -296,7 +351,6 @@ describe('two-pass round trip: printed expression text stays engine-runnable and '', '', ], - [], ], [ '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', @@ -316,7 +370,16 @@ describe('two-pass round trip: printed expression text stays engine-runnable and 'key="a\nb"', 'l1\nl2', ], - ['"a\\tb\\rc"', '"f1\\nf2\\tf3\\rf4"', '"l1\\nl2"'], + [ + 'process p {', + ' service A(label: "a\\tb\\rc", class: "x") {', + ' field f = "f1\\nf2\\tf3\\rf4"', + ' input m = { "a\\nb": "${1}" }', + ' input s = "l1\\nl2"', + ' }', + '}', + '', + ].join('\n'), ], [ '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', @@ -333,7 +396,14 @@ describe('two-pass round trip: printed expression text stays engine-runnable and '${2}', '${3}', ], - ['"\\${dyn}"', '"\\#{other}"'], + [ + 'process p {', + ' service A(class: "x") {', + ' input m = { "\\${dyn}": "${1}", "\\#{other}": "${2}", "plain": "${3}" }', + ' }', + '}', + '', + ].join('\n'), ], [ 'a hyphenated collection variable prints as a bare operaton:collection reference, not a quoted string', @@ -345,7 +415,6 @@ describe('two-pass round trip: printed expression text stays engine-runnable and '', ].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', @@ -361,7 +430,6 @@ describe('two-pass round trip: printed expression text stays engine-runnable and '', '', ], - [], ], [ 'a variable a condition reads comes back declared, so the printed script validates clean', @@ -375,10 +443,6 @@ describe('two-pass round trip: printed expression text stays engine-runnable and [ '${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', @@ -394,7 +458,14 @@ describe('two-pass round trip: printed expression text stays engine-runnable and '', '', ], - [' start FromDesk\n start WhenUrgent condition(urgent)\n}\n'], + [ + 'process claim-review {', + ' var urgent: any', + ' start FromDesk', + ' start WhenUrgent condition(urgent)', + '}', + '', + ].join('\n'), ], [ 'a process-level timer start keeps its date through the round trip', @@ -407,7 +478,6 @@ describe('two-pass round trip: printed expression text stays engine-runnable and [ '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. @@ -432,8 +502,20 @@ describe('two-pass round trip: printed expression text stays engine-runnable and '', ], [ - ' }\n }\n decide Ship6(decision: "approve-claim")\n goto Ship6\n}\n', - ], + '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'), ], [ // The ending branch puts the split's post-dominator at the exit, so the join is read off the routes. @@ -456,8 +538,19 @@ describe('two-pass round trip: printed expression text stays engine-runnable and '', ], [ - ' } else if (b) {\n user Log\n } else {\n service Approve10(class: "org.acme.Audit")\n }\n}\n', - ], + 'process onboarding {', + ' var a: any', + ' var b: any', + ' if (a) {', + ' end Done7', + ' } else if (b) {', + ' user Log', + ' } else {', + ' service Approve10(class: "org.acme.Audit")', + ' }', + '}', + '', + ].join('\n'), ], [ 'a loop inside a branch prints inside it, not as a jump with the loop hoisted behind the end', @@ -468,7 +561,7 @@ describe('two-pass round trip: printed expression text stays engine-runnable and ' await message Wait3("PaymentDone")', ' } while (true)', ' } else if (true) {', - ' throw message("Quote Received")', + ' throw signal("Quote Received")', ' } else {', ' service Escalate(class: "com.example.Delegate")', ' }', @@ -480,9 +573,6 @@ describe('two-pass round trip: printed expression text stays engine-runnable and '', '', ], - [ - ' 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. @@ -493,7 +583,7 @@ describe('two-pass round trip: printed expression text stays engine-runnable and ' var b: any', ' if (a) {', ' user A', - ' throw message("Quote Received")', + ' throw signal("Quote Received")', ' } else if (b) {', ' user B', ' await {', @@ -515,10 +605,6 @@ describe('two-pass round trip: printed expression text stays engine-runnable and '', '', ], - [ - ' 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. @@ -537,7 +623,7 @@ describe('two-pass round trip: printed expression text stays engine-runnable and ' user C', ' end Done', ' user X', - ' throw message("M")', + ' throw signal("M")', '}', '', ].join('\n'), @@ -545,9 +631,6 @@ describe('two-pass round trip: printed expression text stays engine-runnable and '', '', ], - [ - ' 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. @@ -566,9 +649,6 @@ describe('two-pass round trip: printed expression text stays engine-runnable and [ '', ], - [ - ' 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', @@ -591,19 +671,28 @@ describe('two-pass round trip: printed expression text stays engine-runnable and '', ], [ - ' await link("Retry")\n call Call(process: "payment-run")\n on Check6: condition(order.name != items[0]) {\n goto Call\n }\n}\n', - ], + 'process invoice_batch {', + ' var items: any', + ' var order: 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'), ], - ] as const)('%s', async (_title, source, xmlContains, dslContains) => { + ] as const)('%s', async (_title, source, xmlContains, printed = source) => { 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); - } + expect(dsl1).toBe(printed); const { diagnostics } = await validate(dsl1); expect(diagnostics.map((d) => d.message)).toEqual([]); diff --git a/tests/round-trip.test.ts b/tests/round-trip.test.ts index a474613..828242e 100644 --- a/tests/round-trip.test.ts +++ b/tests/round-trip.test.ts @@ -55,14 +55,18 @@ const CORRUPTIONS: [string, (ir: BpmnProcess) => BpmnProcess][] = [ }), ], [ - 'stripping the split gateway default flow', + 'pointing the split gateway default at its conditioned flow', (ir) => ({ ...ir, - flowElements: ir.flowElements.map((fe) => - isGateway(fe) && gatewayDefaultFlowId(fe) !== undefined - ? { kind: fe.kind, id: fe.id, name: fe.name } - : fe, - ), + flowElements: ir.flowElements.map((fe) => { + const declared = isGateway(fe) ? gatewayDefaultFlowId(fe) : undefined; + const other = ir.sequenceFlows.find( + (sf) => sf.sourceRef === fe.id && sf.id !== declared, + ); + return declared === undefined || other === undefined + ? fe + : { ...fe, defaultFlowId: other.id }; + }), }), ], ]; diff --git a/tests/transactions.round-trip.test.ts b/tests/transactions.round-trip.test.ts index 9e04082..edb2da0 100644 --- a/tests/transactions.round-trip.test.ts +++ b/tests/transactions.round-trip.test.ts @@ -93,17 +93,57 @@ it('keeps both kinds of block, the cancel pair and the repetition at every hop, expect(pick(ir), `${title} in ${label}`).toEqual(expected); } } - 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); - } + expect(rt.dslPrime).toBe( + [ + 'process seat-booking {', + ' error PAYMENT_UNAVAILABLE(message: "The payment provider could not be reached")', + ' var seatRows: any', + ' start BookingRequested {', + ' form {', + ' chargeDeclined: boolean "Whether the card issuer declined the charge"', + ' }', + ' }', + ' user ReviewRequest(label: "Review the seat request", assignee: "demo")', + ' attempt BookAndPay(label: "Try to book and pay for the seats") {', + ' start BookingOpened', + ' subprocess HoldSeats(label: "Hold the seats") {', + ' start HoldOpened', + ' service ReserveSeats(label: "Take the seats off sale", class: "com.example.booking.ReserveSeatsDelegate")', + ' end SeatsHeld', + ' on compensation {', + ' start ReleaseOpened', + ' service ReleaseSeats(label: "Put the seats back on sale", class: "com.example.booking.ReleaseSeatsDelegate")', + ' end SeatsReleased', + ' }', + ' }', + ' service ChargeCard(label: "Charge the travel card", class: "com.example.booking.ChargeCardDelegate")', + ' if (chargeDeclined) {', + ' end BookingAbandoned cancel(label: "Give up the booking")', + ' }', + ' service IssueTickets(label: "Issue the boarding tickets", class: "com.example.booking.IssueTicketsDelegate")', + ' end BookingSecured', + ' }', + ' subprocess PrepareDeparture(label: "Prepare the departure list") {', + ' start DepartureOpened', + ' attempt AssignSeatRows for each row in seatRows sequentially(label: "Spread the party across rows", asyncBefore: true) {', + ' service AssignRow(label: "Assign one row of seats", class: "com.example.booking.AssignRowDelegate")', + ' }', + ' end DeparturePrepared', + ' }', + ' user ConfirmBooking(label: "Confirm the seats with the traveler", assignee: "demo")', + ' end BookingComplete', + ' on BookAndPay: cancel {', + ' user ApologizeToTraveler(label: "Apologize and confirm the seats were released", assignee: "demo")', + ' end BookingCancelled', + ' }', + ' on BookAndPay: error(PAYMENT_UNAVAILABLE, code: c, message: m) {', + ' service RecordProviderOutage(label: "Record the payment outage", class: "com.example.booking.RecordOutageDelegate")', + ' end BookingHalted', + ' }', + '}', + '', + ].join('\n'), + ); }); 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', () => { diff --git a/tests/tsconfig.json b/tests/tsconfig.json index d519d79..1870ea2 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -5,7 +5,9 @@ "outDir": "out", "module": "esnext", "moduleResolution": "bundler", - "noEmit": true + "noEmit": true, + "allowImportingTsExtensions": true, + "erasableSyntaxOnly": true }, "references": [ { "path": "../packages/language/tsconfig.src.json" }, diff --git a/tests/value-documentation.round-trip.test.ts b/tests/value-documentation.round-trip.test.ts new file mode 100644 index 0000000..c030feb --- /dev/null +++ b/tests/value-documentation.round-trip.test.ts @@ -0,0 +1,288 @@ +// Documentation inside a condition, a time, a repetition count, a completion +// condition or the variable a repetition names: each prints, compiles back +// into the same child element, and survives an import of that XML. + +import { it, expect } from 'vitest'; + +import { astToIr, irToXml, xmlToIr } from '@bpmn-script/transform'; + +import { compareModels } from './helpers/model-equivalence.js'; +import { + parseToAst, + printDsl, + roundTrip, + validationErrors, +} from './helpers/pipeline.js'; + +const TEXT = 'Ask "${approver}" first.\nThen wait.'; +const QUOTED = '"Ask \\"${approver}\\" first.\\nThen wait."'; + +const processOf = (statements: string[]): string => + [ + 'process p {', + ' var amount: number', + ' var done: boolean', + ' var lines: json', + ...statements.map((line) => ` ${line}`), + '}', + '', + ].join('\n'); + +/** Each documentation child with the element that holds it. */ +function documentedTags(xml: string): [tag: string, text: string][] { + return [ + ...xml.matchAll( + /\s*([^<]*)<\/bpmn:documentation>/g, + ), + ].map((m) => [m[1]!, m[2]!.replace(/ /g, '\n').replace(/"/g, '"')]); +} + +const documentedLines = (dsl: string): string[] => + dsl + .split('\n') + .filter((line) => line.includes('ocumentation:')) + .map((line) => line.trim()); + +async function reimported(xml: string) { + const { ir, warnings } = await xmlToIr(xml); + const dsl = printDsl(ir); + const rebuilt = await xmlToIr(await irToXml(astToIr(await parseToAst(dsl)))); + return { ir, warnings, dsl, rebuilt: rebuilt.ir }; +} + +it.each([ + [ + 'an if condition', + [ + `if (amount > 1000, conditionDocumentation: ${QUOTED}) {`, + ' step A', + '} else {', + ' step B', + '}', + ], + 'conditionExpression', + ], + [ + 'an else-if condition', + [ + 'if (amount > 1000) {', + ' step A', + `} else if (amount > 10, conditionDocumentation: ${QUOTED}) {`, + ' step B', + '} else {', + ' step C', + '}', + ], + 'conditionExpression', + ], + [ + 'a while condition', + [ + `while (amount < 3, conditionDocumentation: ${QUOTED}) {`, + ' step W', + '}', + ], + 'conditionExpression', + ], + [ + 'a do-while condition', + [ + 'do {', + ' step W', + `} while (amount < 3, conditionDocumentation: ${QUOTED})`, + ], + 'conditionExpression', + ], + [ + 'a parallel branch condition', + [ + 'parallel {', + ` if (amount > 1, conditionDocumentation: ${QUOTED}) {`, + ' step P1', + ' }', + ' else {', + ' step P2', + ' }', + '}', + ], + 'conditionExpression', + ], + [ + 'an awaited duration', + [`await timer("PT1H", timerDocumentation: ${QUOTED})`], + 'timeDuration', + ], + [ + 'a start date', + [`start S timer(at: "2099-01-01T00:00:00", timerDocumentation: ${QUOTED})`], + 'timeDate', + ], + [ + 'an awaited condition', + [`await condition(amount > 100, conditionDocumentation: ${QUOTED})`], + 'condition', + ], + [ + 'a boundary timer', + [ + 'user R', + `on R: timer("PT1H", timerDocumentation: ${QUOTED}) {`, + ' step Late', + '}', + ], + 'timeDuration', + ], + [ + 'a timeout listener', + [ + 'user R {', + ` on timeout every "R/PT8H"(class: "a.B", timerDocumentation: ${QUOTED})`, + '}', + ], + 'timeCycle', + ], + [ + 'a scripted timeout listener', + [ + 'user R {', + ` on timeout after "PT8H"(timerDocumentation: ${QUOTED}) \`\`\`groovy`, + ' escalate()', + ' ```', + '}', + ], + 'timeDuration', + ], + [ + 'a repetition count', + [`user R for 3(countDocumentation: ${QUOTED})`], + 'loopCardinality', + ], + [ + 'a repetition variable', + [`user R for each line in lines(eachDocumentation: ${QUOTED})`], + 'inputDataItem', + ], + [ + 'a completion condition', + [`user R for 3 until (done, conditionDocumentation: ${QUOTED})`], + 'completionCondition', + ], +])( + 'documentation on %s prints back where it was written, lands inside its element, and imports back with no difference', + async (_title, statements, tag) => { + const source = processOf(statements); + expect(await validationErrors(source)).toEqual([]); + const run = await roundTrip(source); + expect(documentedTags(run.xml)).toEqual([[tag, TEXT]]); + expect(documentedLines(run.dsl)).toEqual(documentedLines(source)); + + const { ir, warnings, dsl, rebuilt } = await reimported(run.xml); + expect(warnings).toEqual([]); + expect(await validationErrors(dsl)).toEqual([]); + expect(compareModels(ir, rebuilt).differences).toEqual([]); + }, +); + +const IF_SOURCE = processOf([ + `if (amount > 1000, conditionDocumentation: ${QUOTED}) {`, + ' step A', + '}', +]); + +it('keeps the documentation apart from the text of a condition moddle drops for its unprefixed type', async () => { + const { xml } = await roundTrip(IF_SOURCE); + const unprefixed = xml.replace( + 'xsi:type="bpmn:tFormalExpression"', + 'xsi:type="tFormalExpression"', + ); + expect(unprefixed).not.toBe(xml); + const { ir } = await xmlToIr(unprefixed); + expect( + ir.sequenceFlows + .filter((flow) => flow.conditionExpression !== undefined) + .map((flow) => [flow.conditionExpression, flow.conditionDocumentation]), + ).toEqual([['${amount > 1000}', TEXT]]); +}); + +it('reports the documentation of a repetition variable with no name instead of dropping it', async () => { + const { xml } = await roundTrip( + processOf([`user R for each line in lines(eachDocumentation: ${QUOTED})`]), + ); + const unnamed = xml.replace( + '', + '', + ); + expect(unnamed).not.toBe(xml); + const { ir, warnings } = await xmlToIr(unnamed); + expect( + ir.flowElements.flatMap((el) => ('loop' in el ? [el.loop] : [])), + ).toEqual([{ collection: 'lines' }]); + expect(warnings).toEqual([ + { + elementId: 'R', + category: 'documentation', + message: + "documentation on 'R' was not imported\n" + + "note: a 'bpmn:inputDataItem' without a name has no documentation in BPMNscript", + }, + ]); +}); + +it.each([ + [ + "the element's documentation key beside a condition", + ['if (amount > 1, documentation: "x") {', ' step A', '}'], + [ + "'documentation' cannot document a condition\nhelp: write 'conditionDocumentation'", + ], + ], + [ + 'a setting other than conditionDocumentation beside a condition', + ['if (amount > 1, label: "x") {', ' step A', '}'], + [ + "only 'conditionDocumentation' can follow a condition inside its parentheses", + ], + ], + [ + 'a bare value beside a completion condition', + ['user R for 3 until (done, "x")'], + [ + "only 'conditionDocumentation' can follow a condition inside its parentheses", + ], + ], + [ + 'two documentations beside one condition', + [ + 'while (amount < 3, conditionDocumentation: "a", conditionDocumentation: "b") {', + ' step W', + '}', + ], + ["duplicate setting 'conditionDocumentation'"], + ], + [ + 'a count documentation with no count', + ['user R for each line in lines(countDocumentation: "x")'], + [ + "setting 'countDocumentation' documents a count, but the 'for' clause has none", + ], + ], + [ + 'an each documentation with no variable', + ['user R for each in lines(eachDocumentation: "x")'], + [ + "setting 'eachDocumentation' documents the name after 'each', but the 'for' clause has none", + ], + ], + [ + 'a timer documentation on a condition', + ['await condition(amount > 1, timerDocumentation: "x")'], + ["setting 'timerDocumentation' is not valid on an awaited event"], + ], + [ + 'a timer documentation on a listener without a timer', + ['user R {', ' on create(class: "a.B", timerDocumentation: "x")', '}'], + ["setting 'timerDocumentation' is not valid on a listener"], + ], +])('refuses %s', async (_title, statements, errors) => { + expect(await validationErrors(processOf(statements))).toEqual(errors); +});