From a969001967fdb55ca135dcd9a0c13d729a2e6192 Mon Sep 17 00:00:00 2001 From: Marco Schaeck Date: Tue, 27 Oct 2026 11:56:51 +0100 Subject: [PATCH 1/7] docs: add an architecture page for contributors and move the syntax reference under Using the CLI --- README.md | 2 +- docs/.vitepress/config.ts | 8 +- docs/architecture.md | 150 ++++++++++++++++++++++++++++++++++++++ docs/commands.md | 4 +- docs/contract.md | 2 +- docs/reference.md | 2 +- 6 files changed, 159 insertions(+), 9 deletions(-) create mode 100644 docs/architecture.md diff --git a/README.md b/README.md index 1979f5c3..32d55168 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ Details: [library use](https://miragon.github.io/bpmn-cli/library), [validators] The docs site, [miragon.github.io/bpmn-cli](https://miragon.github.io/bpmn-cli/), is built from [docs/](docs/): - [Command reference](https://miragon.github.io/bpmn-cli/commands): every command with its options -- [Reference](https://miragon.github.io/bpmn-cli/reference): ids, kinds, triggers, placement, set keys, output, errors and exit codes +- [Syntax and output](https://miragon.github.io/bpmn-cli/reference): ids, kinds, triggers, placement, set keys, output, errors and exit codes - [Ops JSON](https://miragon.github.io/bpmn-cli/ops): the batch format of `bpmn apply` - [Layout and formatting](https://miragon.github.io/bpmn-cli/layout): layout modes and the diagram-only commands - [The contract and its limits](https://miragon.github.io/bpmn-cli/contract): what the CLI guarantees, what a write changes, limitations diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index a852601e..b7e359ff 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -23,9 +23,8 @@ export default miragonDocs({ link: "/getting-started", activeMatch: "^/(getting-started|claude-code-plugin|walkthrough|contract)", }, - { text: "Commands", link: "/commands", activeMatch: "^/(commands|ops|camunda|layout|bpmnlint)" }, + { text: "Commands", link: "/commands", activeMatch: "^/(commands|reference|ops|camunda|layout|bpmnlint)" }, { text: "Library", link: "/library", activeMatch: "^/(library|validators)" }, - { text: "Reference", link: "/reference", activeMatch: "^/reference" }, ], // One sidebar for every page: the CLI is small enough to show it all. @@ -43,6 +42,7 @@ export default miragonDocs({ text: "Using the CLI", items: [ { text: "Commands", link: "/commands" }, + { text: "Syntax and output", link: "/reference" }, { text: "Ops JSON (bpmn apply)", link: "/ops" }, { text: "Camunda 7 and Camunda 8", link: "/camunda" }, { text: "Layout and formatting", link: "/layout" }, @@ -57,9 +57,9 @@ export default miragonDocs({ ], }, { - text: "Reference", + text: "Contributing", items: [ - { text: "Ids, kinds and keys", link: "/reference" }, + { text: "Architecture", link: "/architecture" }, ], }, ], diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..eb95d168 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,150 @@ +# Architecture + +How `src/` is built, and how a command gets from the terminal into the BPMN model. +`test/architecture.test.ts` checks every rule on this page. The folder table and where new code +goes are in [CONTRIBUTING.md](https://github.com/Miragon/bpmn-cli/blob/main/CONTRIBUTING.md#how-the-package-is-laid-out). + +## One domain, three ways in + +bpmn-cli runs as the `bpmn` command, as a Node library (`@miragon/bpmn-cli/node`) and as a +browser library (`@miragon/bpmn-cli`). All three must change a model in exactly the same way. +So what a change means lives once, in the **domain**, which knows nothing about files, flags +or `process`. Around it, the **application** offers one use case per thing a caller can do. +At the edge, **adapters** translate between the outside world and those use cases. This is a +hexagonal architecture (ports and adapters). + +```mermaid +flowchart LR + terminal(["Terminal"]) --> cli + lib(["Node / browser code"]) --> portIn + + cli["CLI
adapters/in/cli"] --> portIn + portIn{{"Use case
application/port/in"}} --> service["Service
application/service"] + service --> domain["Domain
domain/"] + service --> portOut{{"Port
application/port/out"}} + portOut --> files["Files
adapters/out/files"] + portOut --> memory["Memory
adapters/out/memory"] + + classDef adapter stroke:#2f80ed,stroke-width:2px + classDef app stroke:#f2994a,stroke-width:2px + classDef core stroke:#27ae60,stroke-width:3px + class cli,files,memory adapter + class portIn,service,portOut app + class domain core +``` + +Imports point inwards only: adapters → +application → domain. +The entries in the root of `src/` (`cli.ts`, `node.ts`, `api.ts`) are the only files that know +every layer. They create the services with `new` and pass the adapters in. There is no +container. + +| rule (checked by the test) | why | +| --- | --- | +| `domain/` imports no application, adapter or entry | the domain runs anywhere, also in a browser | +| `application/` imports no adapter | a service does not know whether it writes to disk or to memory | +| only adapters and entries import `node:*` | the browser bundle stays free of Node | +| `adapters/in` knows use cases only by their interface | the entry decides which service is behind it | +| each service implements one use case and calls no other service | one way in, no hidden orchestration | + +## Where each entry comes in + +| entry | calls | adapters behind the ports | differs in | +| --- | --- | --- | --- | +| `bpmn add ...` (`cli.ts`) | `EditDocumentUseCase` through `AddElementCommand` | files + the project's `.bpmnlintrc` | reads and writes the file | +| `mutateFile()` (`node.ts`) | `EditDocumentUseCase` directly | files + the project's `.bpmnlintrc` | no flags, no printing | +| `applyToXml()` (`api.ts`) | `EditDocumentUseCase` directly | memory + no project settings | takes and returns XML, saves nothing, no `.bpmnlintrc` | + +From the use case onwards, all three run the same code. + +## The journey of `bpmn add` + +```sh +bpmn add order.bpmn userTask "Check invoice" --after Event_OrderReceived +``` + +```mermaid +flowchart TB + user(["bpmn add order.bpmn userTask ..."]) + s1["1 · Read the flags
build plain data:
{ op: 'add', kind: 'userTask', name, after }
adapters/in/cli/AddElementCommand.ts"] + s2["2 · Call the use case
useCases.edit.edit({ source: file, commands })
adapters/in/cli/MutationCommand.ts"] + s3["3 · Load
read the file through LoadDocumentPort,
parse it into a Doc, load the project settings
application/service/EditDocumentService.ts"] + s4["4 · Run the batch
validate, change, lay out, keep the text
domain/batch/EditBatchService.ts"] + s5["5 · Pick the service for the op
'add' → ElementCreationService
domain/batch/ModelCommandService.ts"] + s6["6 · Change the model
create the task, wire the sequence flow
domain/editing/ElementCreationService.ts"] + s7["7 · Save
write the XML through SaveDocumentPort
adapters/out/files/FileDocumentStore.ts"] + s8["8 · Print
created Activity_CheckInvoice … / layout: ok
adapters/in/report/MutationText.ts"] + + user --> s1 --> s2 --> s3 --> s4 --> s5 --> s6 + s6 -- "what changed" --> s4 + s4 -- "new XML" --> s7 --> s8 + + classDef adapter stroke:#2f80ed,stroke-width:2px + classDef app stroke:#f2994a,stroke-width:2px + classDef core stroke:#27ae60,stroke-width:3px + class s1,s2,s7,s8 adapter + class s3 app + class s4,s5,s6 core +``` + +The colours are the layers from the first picture. `bpmn apply` differs only in step 1: it +reads a JSON list of the same commands instead of flags. `applyToXml()` starts at step 3, +gets the XML as a string and skips step 7. + +The CLI learns about `add` at startup: `new Cli(...)` in `cli.ts` creates every command class, +and each one registers its verb and flags with commander. + +## Inside the domain: one batch + +A call can carry many commands (`bpmn apply`), so the domain always runs a **batch**. +`EditBatchService.run()` never touches the file system, which lets the browser run the same +code. Nothing is written unless the whole batch passes. + +```mermaid +flowchart LR + in(["Doc + commands"]) --> before["Remember the state before
drawing · findings · text"] + before --> each["Run each command
in order"] + each --> check["Validate
new errors refuse the write"] + check --> draw["Lay out
the drawing"] + draw --> text["Keep the text
rewrite only what changed"] + text --> out(["New XML + result"]) + + classDef core stroke:#27ae60,stroke-width:2px + class before,each,check,draw,text core +``` + +A few words you meet in this code: + +- **Model command / diagram command**: `add`, `connect`, `set`, … change what the process + *means*. `place`, `align`, `color`, `route`, … change only where things are *drawn*. Diagram + commands run on the drawing, after the model commands before them. +- **Alias**: a name a batch gives to an element it creates (`"as": "check"`), so a later + command in the same batch can refer to it before its final id is known. +- **Doc**: the parsed file, a tree of [bpmn-moddle](https://github.com/bpmn-io/bpmn-moddle) + objects plus the original text. +- **Keep the text**: only the parts of the file that a command changed are rewritten. + Formatting, comments and attribute order everywhere else stay as they were. + +The domain folders build on each other without a cycle. `document/` (the `Doc`, ids, errors) +is at the bottom, and `batch/` is at the top, imported by nothing in the domain. + +## One definition per command + +Each command (`add`, `connect`, …) is plain data with one **definition** in +`domain/commands/definitions/`. The definition names its fields, which of them hold ids, and +its checks. Every place that needs to know a command reads the definition, so they cannot drift +apart: + +```mermaid +flowchart LR + def["AddElementDefinition"] --> parser["bpmn apply
accepts and checks the JSON"] + def --> schema["OPS_SCHEMA
for agents and editors"] + def --> help["bpmn add --help
option texts"] + + classDef core stroke:#27ae60,stroke-width:2px + class def core +``` + +A new command therefore needs a definition, a `case` in `ModelCommandService` and a service in +`domain/editing/`. `bpmn apply` and `applyToXml()` accept it from then on. A verb of its own on +the command line (`bpmn `) is optional and lives in `adapters/in/cli/`. diff --git a/docs/commands.md b/docs/commands.md index 72ca828c..6b280a98 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -29,7 +29,7 @@ of kinds, keys and error codes. | Help | [`kinds`, `guide`](#kinds-and-guide) | reference tables, cheat sheet for agents | `-` as the file reads the model from stdin; a mutating command then writes to stdout. Ids, kinds and -keys are in [Ids, kinds and keys](reference.md). +keys are in [Syntax and output](reference.md). ## Options of every write @@ -232,4 +232,4 @@ is its core, `guide ` one part. - [Ops JSON](ops.md): many changes in one batch. - [Layout and formatting](layout.md): how the drawing is kept, and the format commands. -- [Ids, kinds and keys](reference.md): every kind, trigger and key. +- [Syntax and output](reference.md): every kind, trigger and key. diff --git a/docs/contract.md b/docs/contract.md index 81d4eedb..0357f3fb 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -77,5 +77,5 @@ merged file (`E_IMPORT_LOSSY`): rename one of them while resolving the merge. ## Next steps - [Layout](layout.md): modes, engines and the format commands. -- [Ids, kinds and keys](reference.md): how ids are made. +- [Syntax and output](reference.md): how ids are made. - [bpmnlint](bpmnlint.md): project rules on every write. diff --git a/docs/reference.md b/docs/reference.md index 0559faa5..9c574af8 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -1,4 +1,4 @@ -# Ids, kinds and keys +# Syntax and output How new ids are made, the element kinds and triggers, where a node goes, the keys `set` takes, and what a command prints and exits with. From 928b31b6bbb7b1a25a19eb0658f9648ac47a450d Mon Sep 17 00:00:00 2001 From: Marco Schaeck Date: Tue, 27 Oct 2026 11:56:51 +0100 Subject: [PATCH 2/7] chore(docs): vendor docs kit 1.26.0 with full-screen images and diagrams --- .github/workflows/ci.yml | 2 +- docs/.vitepress/miragon/manifest.json | 11 ++- docs/.vitepress/miragon/theme/ZoomOverlay.vue | 81 +++++++++++++++++++ docs/.vitepress/miragon/theme/index.ts | 6 +- docs/.vitepress/miragon/theme/miragon.css | 70 ++++++++++++++++ 5 files changed, 164 insertions(+), 6 deletions(-) create mode 100644 docs/.vitepress/miragon/theme/ZoomOverlay.vue diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f59caaf0..9390c036 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,7 +76,7 @@ jobs: if: env.CI_SOURCE_TOKEN != '' with: repository: Miragon/corporate-identity - ref: v1.10.0 + ref: v1.26.0 token: ${{ env.CI_SOURCE_TOKEN }} path: .ci-source sparse-checkout: plugins/miragon-brand diff --git a/docs/.vitepress/miragon/manifest.json b/docs/.vitepress/miragon/manifest.json index efd84433..e269c786 100644 --- a/docs/.vitepress/miragon/manifest.json +++ b/docs/.vitepress/miragon/manifest.json @@ -1,6 +1,6 @@ { "$kommentar": "Generiert von scripts/sync-assets.mjs in Miragon/corporate-identity, nicht von Hand ändern. Jede Datei, die ein Repo aus der Skill docs-site vendort: Pfad im Repo, Quelle unter plugins/miragon-brand/skills/docs-site/assets/ und sha256. check.mjs prüft die Kopie damit.", - "version": "1.25.1", + "version": "1.26.0", "dateien": [ { "pfad": "docs/.vitepress/miragon/check.mjs", @@ -42,6 +42,11 @@ "quelle": "miragon/theme/StarPrompt.vue", "sha256": "38a7de32637193b43f9eb3dedd79584a9f11ee1a0ffd4f428aa2f30ba3af18fa" }, + { + "pfad": "docs/.vitepress/miragon/theme/ZoomOverlay.vue", + "quelle": "miragon/theme/ZoomOverlay.vue", + "sha256": "387a2ecd0465e4b158431f34edb7dea2feff5a62cf84b5622520c43829c290c7" + }, { "pfad": "docs/.vitepress/miragon/theme/cd-tokens.generated.css", "quelle": "miragon/theme/cd-tokens.generated.css", @@ -50,12 +55,12 @@ { "pfad": "docs/.vitepress/miragon/theme/index.ts", "quelle": "miragon/theme/index.ts", - "sha256": "dda90b8c056e558ac01f2de1a4f38c918d50339af5d59c87ddd710e09195ccd9" + "sha256": "450a3ab3603426b0b7aa1d751976998b5e52341ed110245ada6658f9d1097b99" }, { "pfad": "docs/.vitepress/miragon/theme/miragon.css", "quelle": "miragon/theme/miragon.css", - "sha256": "5c43dc675cf7d718e42f7089e1bb274f1198bf1049d7997b108fe3724f05a4df" + "sha256": "3253247af1fb53006694a2db7a3a1d001cb6875dfb64fbb55a840c062df74344" }, { "pfad": "docs/.vitepress/miragon/theme/useStars.ts", diff --git a/docs/.vitepress/miragon/theme/ZoomOverlay.vue b/docs/.vitepress/miragon/theme/ZoomOverlay.vue new file mode 100644 index 00000000..6401a006 --- /dev/null +++ b/docs/.vitepress/miragon/theme/ZoomOverlay.vue @@ -0,0 +1,81 @@ + + + + diff --git a/docs/.vitepress/miragon/theme/index.ts b/docs/.vitepress/miragon/theme/index.ts index 1887fe09..204bddfc 100644 --- a/docs/.vitepress/miragon/theme/index.ts +++ b/docs/.vitepress/miragon/theme/index.ts @@ -29,6 +29,7 @@ import "./miragon.css" import LegalRow from "./LegalRow.vue" import GitHubStars from "./GitHubStars.vue" import StarPrompt from "./StarPrompt.vue" +import ZoomOverlay from "./ZoomOverlay.vue" // Im Titel jeder Seite steht der Komet statt der Wortmarke, daneben der Produktname // (theme/miragon.css, Abschnitt TITEL: KOMET UND PRODUKTNAME). @@ -40,7 +41,8 @@ const Komet: FunctionalComponent = () => h("path", { d: KOMET, fill: "#00E676" }), ]) // Das Layout von VitePress mit GitHub und Sternen im Header, der Bitte um einen Stern am -// Seitenende und der Rechtsleiste ganz unten. Slots von außen reicht es weiter, +// Seitenende, der Rechtsleiste ganz unten und dem Vollbild für Bilder und Diagramme +// (ZoomOverlay.vue). Slots von außen reicht es weiter, // auch einen eigenen layout-bottom, der dann über der Rechtsleiste steht. const Layout: FunctionalComponent = (_, { slots }) => h(DefaultTheme.Layout, null, { @@ -48,7 +50,7 @@ const Layout: FunctionalComponent = (_, { slots }) => "nav-bar-title-before": () => [slots["nav-bar-title-before"]?.(), h(Komet)], "nav-bar-content-after": () => [slots["nav-bar-content-after"]?.(), h(GitHubStars)], "doc-footer-before": () => [slots["doc-footer-before"]?.(), h(StarPrompt)], - "layout-bottom": () => [slots["layout-bottom"]?.(), h(LegalRow)], + "layout-bottom": () => [slots["layout-bottom"]?.(), h(LegalRow), h(ZoomOverlay)], }) const MiragonTheme: Theme = { diff --git a/docs/.vitepress/miragon/theme/miragon.css b/docs/.vitepress/miragon/theme/miragon.css index 37a3aa9c..958ba867 100644 --- a/docs/.vitepress/miragon/theme/miragon.css +++ b/docs/.vitepress/miragon/theme/miragon.css @@ -643,6 +643,76 @@ color: var(--cd-gruen); } +/* ========================================================= + ZOOM + Bilder, Mermaid-Diagramme und Elemente mit data-zoom öffnen sich im + Vollbild (ZoomOverlay.vue). Das Overlay ist die Seite selbst, ohne + Deckkraft, damit die Farben des Diagramms hell und dunkel bleiben. Die + Kopie wächst bis an den Rand, das Seitenverhältnis bleibt. + ========================================================= */ +.vp-doc :is(img, .mermaid, [data-zoom]):not([data-zoom="off"], [data-zoom="off"] *, a *) { + cursor: zoom-in; +} +.miragon-zoom-offen { + overflow: hidden; +} +.miragon-zoom { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + background: var(--cd-ui-seite); + cursor: zoom-out; +} +.miragon-zoom-buehne { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + min-width: 0; + padding: 56px 24px 24px; + overflow: auto; +} +.miragon-zoom-kopie { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + margin: 0; + cursor: default; +} +.miragon-zoom-kopie svg, +img.miragon-zoom-kopie, +.miragon-zoom-kopie img { + width: 100%; + height: 100%; + max-width: none !important; + object-fit: contain; +} +.miragon-zoom-zu { + position: absolute; + top: 12px; + right: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: 1px solid var(--cd-ui-linie); + border-radius: 999px; + background: var(--cd-ui-seite); + color: var(--cd-ui-text); + cursor: pointer; + transition: border-color 0.25s; +} +.miragon-zoom-zu:hover { + border-color: var(--cd-ui-kontur); +} +.miragon-zoom-zu svg { + width: 14px; +} + /* ========================================================= FOKUS Sichtbarer Tastatur-Fokus über die Rolle: hell blau-link, dunkel blau-hell. From 5dc4e44d19b83caeb7f708b9009998b9a1c79532 Mon Sep 17 00:00:00 2001 From: Marco Schaeck Date: Tue, 27 Oct 2026 12:02:26 +0100 Subject: [PATCH 3/7] docs(architecture): compact journey diagram with step-by-step details, drop the domain internals --- docs/architecture.md | 143 ++++++++++++++++++++++++++----------------- 1 file changed, 87 insertions(+), 56 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index eb95d168..d7887e8b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -47,16 +47,6 @@ container. | `adapters/in` knows use cases only by their interface | the entry decides which service is behind it | | each service implements one use case and calls no other service | one way in, no hidden orchestration | -## Where each entry comes in - -| entry | calls | adapters behind the ports | differs in | -| --- | --- | --- | --- | -| `bpmn add ...` (`cli.ts`) | `EditDocumentUseCase` through `AddElementCommand` | files + the project's `.bpmnlintrc` | reads and writes the file | -| `mutateFile()` (`node.ts`) | `EditDocumentUseCase` directly | files + the project's `.bpmnlintrc` | no flags, no printing | -| `applyToXml()` (`api.ts`) | `EditDocumentUseCase` directly | memory + no project settings | takes and returns XML, saves nothing, no `.bpmnlintrc` | - -From the use case onwards, all three run the same code. - ## The journey of `bpmn add` ```sh @@ -65,68 +55,109 @@ bpmn add order.bpmn userTask "Check invoice" --after Event_OrderReceived ```mermaid flowchart TB - user(["bpmn add order.bpmn userTask ..."]) - s1["1 · Read the flags
build plain data:
{ op: 'add', kind: 'userTask', name, after }
adapters/in/cli/AddElementCommand.ts"] - s2["2 · Call the use case
useCases.edit.edit({ source: file, commands })
adapters/in/cli/MutationCommand.ts"] - s3["3 · Load
read the file through LoadDocumentPort,
parse it into a Doc, load the project settings
application/service/EditDocumentService.ts"] - s4["4 · Run the batch
validate, change, lay out, keep the text
domain/batch/EditBatchService.ts"] - s5["5 · Pick the service for the op
'add' → ElementCreationService
domain/batch/ModelCommandService.ts"] - s6["6 · Change the model
create the task, wire the sequence flow
domain/editing/ElementCreationService.ts"] - s7["7 · Save
write the XML through SaveDocumentPort
adapters/out/files/FileDocumentStore.ts"] - s8["8 · Print
created Activity_CheckInvoice … / layout: ok
adapters/in/report/MutationText.ts"] - - user --> s1 --> s2 --> s3 --> s4 --> s5 --> s6 - s6 -- "what changed" --> s4 - s4 -- "new XML" --> s7 --> s8 + user(["bpmn add"]) --> s1["① AddElementCommand"] --> s2{{"② EditDocumentUseCase"}} --> s3["③ EditDocumentService"] --> s4["④ EditBatchService"] --> s5["⑤ ModelCommandService"] --> s6["⑥ ElementCreationService"] + s3 <-- "read · write" --> s7[("⑦ FileDocumentStore")] + s3 -- result --> s8["⑧ MutationText"] classDef adapter stroke:#2f80ed,stroke-width:2px classDef app stroke:#f2994a,stroke-width:2px classDef core stroke:#27ae60,stroke-width:3px - class s1,s2,s7,s8 adapter - class s3 app + class s1,s7,s8 adapter + class s2,s3 app class s4,s5,s6 core ``` -The colours are the layers from the first picture. `bpmn apply` differs only in step 1: it -reads a JSON list of the same commands instead of flags. `applyToXml()` starts at step 3, -gets the XML as a string and skips step 7. +Eight stops, coloured by layer as in the first picture: adapters, application, domain. The file system (⑦) is only touched by the +service, through a port; the domain never sees it. -The CLI learns about `add` at startup: `new Cli(...)` in `cli.ts` creates every command class, -and each one registers its verb and flags with commander. +### ① Flags become data -## Inside the domain: one batch +`adapters/in/cli/AddElementCommand.ts` registered `add` with commander when the CLI started. +Now it checks the flags and turns them into a plain object, the same shape `bpmn apply` reads +from JSON: -A call can carry many commands (`bpmn apply`), so the domain always runs a **batch**. -`EditBatchService.run()` never touches the file system, which lets the browser run the same -code. Nothing is written unless the whole batch passes. +```ts +const op: AddElement = { + op: 'add', + kind: 'userTask', + name: 'Check invoice', + after: 'Event_OrderReceived', +} +await this.mutate(file, [op], opts) +``` -```mermaid -flowchart LR - in(["Doc + commands"]) --> before["Remember the state before
drawing · findings · text"] - before --> each["Run each command
in order"] - each --> check["Validate
new errors refuse the write"] - check --> draw["Lay out
the drawing"] - draw --> text["Keep the text
rewrite only what changed"] - text --> out(["New XML + result"]) +From here on nobody knows about flags any more. - classDef core stroke:#27ae60,stroke-width:2px - class before,each,check,draw,text core +### ② Through the port + +`mutate()` in `adapters/in/cli/MutationCommand.ts` calls the use case. The CLI only knows the +interface `EditDocumentUseCase`; which service is behind it, `cli.ts` decided at startup. + +```ts +this.cli.useCases.edit.edit({ source: { file }, commands: [op], ...opts }) +``` + +### ③ Load, run, save + +`application/service/EditDocumentService.ts` is short on purpose: get the document, let the +domain work, write the result. + +```ts +const doc = await Doc.fromXml(await this.load.read(file), file) // ⑦ read +const result = await new EditBatchService(doc, commands, opts).run(target) // ④ +await this.save.save(target, result.xml, …) // ⑦ write +``` + +`this.load` and `this.save` are ports. In the CLI they are a `FileDocumentStore`, in +`applyToXml()` an `InMemoryDocumentStore`; the service cannot tell. + +### ④ The batch + +`domain/batch/EditBatchService.ts` runs all commands of one call on the `Doc` in memory, then +validates, lays out the drawing and writes only the changed parts back into the original text. +If anything fails, nothing is written. + +### ⑤ The right service for the op + +`domain/batch/ModelCommandService.ts` hands each command to the service that knows it: + +```ts +switch (op.op) { + case 'add': return new ElementCreationService(doc, this.edit).add(op) + case 'connect': return new ConnectionService(doc, this.edit).connect(op) + … +} +``` + +### ⑥ The model changes + +`domain/editing/ElementCreationService.ts` creates the user task and the sequence flow from +`Event_OrderReceived`, and reports what it did as a `ChangeSet` (created, changed, removed). + +### ⑦ The file + +`adapters/out/files/FileDocumentStore.ts` reads the file at ③ and writes it back atomically at +the end, through `LoadDocumentPort` and `SaveDocumentPort`. + +### ⑧ The answer + +Back in the CLI, `adapters/in/report/MutationText.ts` turns the result into what you see: + +```text +created userTask Activity_CheckInvoice "Check invoice" - after Event_OrderReceived +created sequenceFlow Flow_OrderReceivedToCheckInvoice - Event_OrderReceived -> Activity_CheckInvoice +layout: ok - full (engine-owned diagram: redrawn) +written: order.bpmn ``` -A few words you meet in this code: +### Other ways in -- **Model command / diagram command**: `add`, `connect`, `set`, … change what the process - *means*. `place`, `align`, `color`, `route`, … change only where things are *drawn*. Diagram - commands run on the drawing, after the model commands before them. -- **Alias**: a name a batch gives to an element it creates (`"as": "check"`), so a later - command in the same batch can refer to it before its final id is known. -- **Doc**: the parsed file, a tree of [bpmn-moddle](https://github.com/bpmn-io/bpmn-moddle) - objects plus the original text. -- **Keep the text**: only the parts of the file that a command changed are rewritten. - Formatting, comments and attribute order everywhere else stay as they were. +- `bpmn apply` differs only at ①: it reads the commands as JSON instead of flags. +- `mutateFile()` (`node.ts`) starts at ②. +- `applyToXml()` (`api.ts`) starts at ②, with memory at ⑦ and no project `.bpmnlintrc`, and + returns the XML instead of saving it. -The domain folders build on each other without a cycle. `document/` (the `Doc`, ids, errors) -is at the bottom, and `batch/` is at the top, imported by nothing in the domain. +From ② onwards, all of them run the same code. ## One definition per command From c2a74b589c9ecdcbec9bb05251062b62cf3e1058 Mon Sep 17 00:00:00 2001 From: Marco Schaeck Date: Tue, 27 Oct 2026 12:04:40 +0100 Subject: [PATCH 4/7] docs(architecture): show each journey step as a shortened real function, diagrams top down, plain step numbers --- docs/architecture.md | 214 ++++++++++++++++++++++++++++++++----------- 1 file changed, 161 insertions(+), 53 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index d7887e8b..d88e4da5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -14,7 +14,7 @@ At the edge, **adapters** translate between the outside world and those use case hexagonal architecture (ports and adapters). ```mermaid -flowchart LR +flowchart TB terminal(["Terminal"]) --> cli lib(["Node / browser code"]) --> portIn @@ -33,8 +33,8 @@ flowchart LR class domain core ``` -Imports point inwards only: adapters → -application → domain. +The frame colour shows the layer: blue for adapters, orange for the application, green for the +domain. Imports point inwards only: adapters → application → domain. The entries in the root of `src/` (`cli.ts`, `node.ts`, `api.ts`) are the only files that know every layer. They create the services with `new` and pass the adapters in. There is no container. @@ -55,9 +55,9 @@ bpmn add order.bpmn userTask "Check invoice" --after Event_OrderReceived ```mermaid flowchart TB - user(["bpmn add"]) --> s1["① AddElementCommand"] --> s2{{"② EditDocumentUseCase"}} --> s3["③ EditDocumentService"] --> s4["④ EditBatchService"] --> s5["⑤ ModelCommandService"] --> s6["⑥ ElementCreationService"] - s3 <-- "read · write" --> s7[("⑦ FileDocumentStore")] - s3 -- result --> s8["⑧ MutationText"] + user(["bpmn add"]) --> s1["1 · AddElementCommand"] --> s2{{"2 · EditDocumentUseCase"}} --> s3["3 · EditDocumentService"] --> s4["4 · EditBatchService"] --> s5["5 · ModelCommandService"] --> s6["6 · ElementCreationService"] + s3 <-- "read · write" --> s7[("7 · FileDocumentStore")] + s3 -- result --> s8["8 · MutationText"] classDef adapter stroke:#2f80ed,stroke-width:2px classDef app stroke:#f2994a,stroke-width:2px @@ -67,81 +67,189 @@ flowchart TB class s4,s5,s6 core ``` -Eight stops, coloured by layer as in the first picture: adapters, application, domain. The file system (⑦) is only touched by the +Eight stops, framed in the colours of their layer. The file system (7) is only touched by the service, through a port; the domain never sees it. -### ① Flags become data +The snippets below are the real functions, shortened: checks, options and edge cases are left +out (`…`), the calls that hand over to the next stop are kept. + +### 1. Flags become data -`adapters/in/cli/AddElementCommand.ts` registered `add` with commander when the CLI started. -Now it checks the flags and turns them into a plain object, the same shape `bpmn apply` reads -from JSON: +When the CLI starts, `cli.ts` creates the `Cli`, and every command class registers its verb with +commander. For `add`, commander later calls `run()` with the parsed flags. It turns them into a +plain object, the same shape `bpmn apply` reads from JSON, and hands it on. ```ts -const op: AddElement = { - op: 'add', - kind: 'userTask', - name: 'Check invoice', - after: 'Event_OrderReceived', +// src/adapters/in/cli/AddElementCommand.ts +export class AddElementCommand extends MutationCommand { + register(program: Command): void { + this.define(program, 'add') + .option('--after ') + .option('--before ') + // … + .action((file, kind, name, keyValues, options) => this.run(file, kind, name, keyValues, options)) + } + + private async run(file: string, kind: string, name: string | undefined, keyValues: string[], options: RawOpts) { + const opts = this.mutationOptions(options) + await this.cli.guarded(async () => { + this.checkPlacementFlags(options) + const op: AddElement = { + op: 'add', + kind, // 'userTask' + name, // 'Check invoice' + after: options['after'], // 'Event_OrderReceived' + // … + } + await this.mutate(file, [op], opts) + }, opts.json) + } } -await this.mutate(file, [op], opts) ``` -From here on nobody knows about flags any more. +`guarded()` catches any error and prints it with its code and hint. From here on nobody knows +about flags any more. -### ② Through the port +### 2. Through the port -`mutate()` in `adapters/in/cli/MutationCommand.ts` calls the use case. The CLI only knows the -interface `EditDocumentUseCase`; which service is behind it, `cli.ts` decided at startup. +`mutate()` lives in the base class of every changing command. It calls the use case and prints +what comes back. The CLI only knows the interface `EditDocumentUseCase`; which service is +behind it, `cli.ts` decided at startup. + +```ts +// src/adapters/in/cli/MutationCommand.ts +protected async mutate(file: string, ops: EditCommand[], opts: MutationFlags) { + const result = await this.cli.useCases.edit.edit({ ...opts, source: { file }, commands: ops }) + await this.printMutation(result, opts, this.cli.io.stdout) // step 8 +} +``` ```ts -this.cli.useCases.edit.edit({ source: { file }, commands: [op], ...opts }) +// src/application/port/in/EditDocumentUseCase.ts +export interface EditDocumentUseCase { + edit(command: { source: DocumentSource; commands: EditCommand[] } & FileMutationOptions): Promise +} ``` -### ③ Load, run, save +### 3. Load, run, save -`application/service/EditDocumentService.ts` is short on purpose: get the document, let the -domain work, write the result. +The service is short on purpose: get the document, let the domain work, write the result. +`load`, `save` and `settings` are outbound ports, handed in by the constructor. ```ts -const doc = await Doc.fromXml(await this.load.read(file), file) // ⑦ read -const result = await new EditBatchService(doc, commands, opts).run(target) // ④ -await this.save.save(target, result.xml, …) // ⑦ write +// src/application/service/EditDocumentService.ts +export class EditDocumentService implements EditDocumentUseCase { + constructor( + private readonly load: LoadDocumentPort, + private readonly save: SaveDocumentPort, + private readonly settings: LoadProjectSettingsPort, + ) {} + + async edit({ source, commands, ...opts }: EditDocumentUseCase.Command) { + const doc = 'doc' in source ? source.doc : await Doc.fromXml(await this.load.read(source.file), source.file) // step 7 + const target = opts.out ?? doc.file + const lint = await this.settings.lint(target, opts) // .bpmnlintrc + const result = await new EditBatchService(doc, commands, lint.applyTo(opts, target)).run(target) // step 4 + if (!opts.dryRun) result.written = await this.save.save(target, result.xml, { /* … */ }) // step 7 + return result + } +} ``` -`this.load` and `this.save` are ports. In the CLI they are a `FileDocumentStore`, in -`applyToXml()` an `InMemoryDocumentStore`; the service cannot tell. +In the CLI the ports are a `FileDocumentStore` and `BpmnlintProjectSettings`; in `applyToXml()` +they are an `InMemoryDocumentStore` and `NoProjectSettings`. The service cannot tell. -### ④ The batch +### 4. The batch -`domain/batch/EditBatchService.ts` runs all commands of one call on the `Doc` in memory, then -validates, lays out the drawing and writes only the changed parts back into the original text. -If anything fails, nothing is written. +The domain starts here. `EditBatchService` runs all commands of one call on the `Doc` in +memory, then validates, lays out the drawing and writes only the changed parts back into the +original text. If anything throws, nothing is written: the caller keeps its input. -### ⑤ The right service for the op +```ts +// src/domain/batch/EditBatchService.ts +async run(file = this.doc.file): Promise { + await this.guard() // lossless import, C7/C8 rules loaded + const findings = await FindingBaseline.capture(this.doc, …) // warnings that were already there + const diagram = await BatchDrawing.capture(this.doc, …) // the drawing before + const { batch } = await this.runCommands(diagram) // step 5 + await this.validate(findings, batch) // new errors refuse the write + const drawn = await diagram.apply(this.edit, batch.ops, batch.changes) // layout + const xml = await this.keptText(drawn.xml, …) // rewrite only what changed + return this.result(file, batch, xml, drawn.layout, …) +} +``` -`domain/batch/ModelCommandService.ts` hands each command to the service that knows it: +### 5. The right service for the op + +`ModelCommandService` runs the commands in order and hands each one to the service that knows +it. Commands that only move shapes (`place`, `align`, `color`, …) are left for the layout in +step 4. ```ts -switch (op.op) { - case 'add': return new ElementCreationService(doc, this.edit).add(op) - case 'connect': return new ConnectionService(doc, this.edit).connect(op) - … +// src/domain/batch/ModelCommandService.ts +private dispatch(op: EditCommand): ChangeSet { + if (isDiagramCommand(op)) return new ChangeSet() + switch (op.op) { + case 'add': return new ElementCreationService(this.doc, this.edit).add(op) + case 'connect': return new ConnectionService(this.doc, this.edit).connect(op) + case 'set': return new PropertyService(this.doc, this.edit).set(op) + case 'remove': return new ElementRemovalService(this.doc, this.edit).remove(op) + // … retype, move, order, ext, split + } } ``` -### ⑥ The model changes +### 6. The model changes + +`ElementCreationService` makes the new element in the bpmn-moddle tree of the `Doc`, places it +after `Event_OrderReceived` (which creates the sequence flow), and records everything it did in +a `ChangeSet`. -`domain/editing/ElementCreationService.ts` creates the user task and the sequence flow from -`Event_OrderReceived`, and reports what it did as a `ChangeSet` (created, changed, removed). +```ts +// src/domain/editing/ElementCreationService.ts +add(op: AddElement): ChangeSet { + const cs = new ChangeSet() + const { def, trigger } = ElementCreationService.resolveKind(op.kind) // 'userTask' → bpmn:UserTask + const el = this.addFlowNode(op, def, trigger, cs) + if (op.set) cs.merge(new PropertyService(this.doc, this.edit).set({ op: 'set', id: idOf(el), values: op.set })) + return cs +} -### ⑦ The file +private addFlowNode(op: AddElement, def: KindDef, trigger: Trigger | undefined, cs: ChangeSet): El { + const id = this.edit.allocator.explicitOr(op.id, …) // 'Activity_CheckInvoice' + const el = this.doc.create(def.type, { id, name: op.name }) + cs.create({ id, kind: def.kind, name: op.name }) + new NodePlacementService(this.doc, this.edit, op).place(el, cs) // --after: splice in, add the flow + return el +} +``` -`adapters/out/files/FileDocumentStore.ts` reads the file at ③ and writes it back atomically at -the end, through `LoadDocumentPort` and `SaveDocumentPort`. +### 7. The file + +`FileDocumentStore` implements both file ports. It is the only class on this path that imports +`node:fs`. + +```ts +// src/adapters/out/files/FileDocumentStore.ts +export class FileDocumentStore implements LoadDocumentPort, SaveDocumentPort { + read(file: string): Promise { + return readXml(file) // decoded in the encoding the XML declares + } + + async save(target: string, xml: string, opts: { source?: string; unchanged: boolean; backup?: boolean }) { + if (opts.unchanged && target === opts.source) return false // no-op: leave the file alone + if (opts.backup && opts.source) await FileDocumentStore.backup(opts.source) + await writeAtomic(target, xml) // a failed write leaves the old file + return true + } +} +``` -### ⑧ The answer +### 8. The answer -Back in the CLI, `adapters/in/report/MutationText.ts` turns the result into what you see: +Back in `MutationCommand`, the result becomes a report (`MutationReportBuilder`) and then text +(`MutationText`), both in `adapters/in/report/`. The library returns the same report, so the +CLI and `applyToXml()` describe a change in the same words. ```text created userTask Activity_CheckInvoice "Check invoice" - after Event_OrderReceived @@ -152,12 +260,12 @@ written: order.bpmn ### Other ways in -- `bpmn apply` differs only at ①: it reads the commands as JSON instead of flags. -- `mutateFile()` (`node.ts`) starts at ②. -- `applyToXml()` (`api.ts`) starts at ②, with memory at ⑦ and no project `.bpmnlintrc`, and - returns the XML instead of saving it. +- `bpmn apply` differs only in step 1: it reads the commands as JSON instead of flags. +- `mutateFile()` (`node.ts`) starts at step 2. +- `applyToXml()` (`api.ts`) starts at step 2, with memory in step 7 and no project + `.bpmnlintrc`, and returns the XML instead of saving it. -From ② onwards, all of them run the same code. +From step 2 onwards, all of them run the same code. ## One definition per command From 39208b1d76d8d5d956672ef502c1647168a05837 Mon Sep 17 00:00:00 2001 From: Marco Schaeck Date: Tue, 27 Oct 2026 12:06:04 +0100 Subject: [PATCH 5/7] docs(architecture): wrap the code snippets to the column width --- docs/architecture.md | 136 ++++++++++++++++++++++++++++++++----------- 1 file changed, 103 insertions(+), 33 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index d88e4da5..c192d6c7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -87,10 +87,18 @@ export class AddElementCommand extends MutationCommand { .option('--after ') .option('--before ') // … - .action((file, kind, name, keyValues, options) => this.run(file, kind, name, keyValues, options)) + .action((file, kind, name, keyValues, options) => + this.run(file, kind, name, keyValues, options), + ) } - private async run(file: string, kind: string, name: string | undefined, keyValues: string[], options: RawOpts) { + private async run( + file: string, + kind: string, + name: string | undefined, + keyValues: string[], + options: RawOpts, + ) { const opts = this.mutationOptions(options) await this.cli.guarded(async () => { this.checkPlacementFlags(options) @@ -118,16 +126,32 @@ behind it, `cli.ts` decided at startup. ```ts // src/adapters/in/cli/MutationCommand.ts -protected async mutate(file: string, ops: EditCommand[], opts: MutationFlags) { - const result = await this.cli.useCases.edit.edit({ ...opts, source: { file }, commands: ops }) - await this.printMutation(result, opts, this.cli.io.stdout) // step 8 +protected async mutate( + file: string, + ops: EditCommand[], + opts: MutationFlags, +) { + const result = await this.cli.useCases.edit.edit({ + ...opts, + source: { file }, + commands: ops, + }) + // step 8 + await this.printMutation(result, opts, this.cli.io.stdout) } ``` ```ts // src/application/port/in/EditDocumentUseCase.ts export interface EditDocumentUseCase { - edit(command: { source: DocumentSource; commands: EditCommand[] } & FileMutationOptions): Promise + edit(command: EditDocumentUseCase.Command): Promise +} + +export declare namespace EditDocumentUseCase { + interface Command extends FileMutationOptions { + source: DocumentSource // { file } or an already loaded { doc } + commands: EditCommand[] + } } ``` @@ -146,11 +170,24 @@ export class EditDocumentService implements EditDocumentUseCase { ) {} async edit({ source, commands, ...opts }: EditDocumentUseCase.Command) { - const doc = 'doc' in source ? source.doc : await Doc.fromXml(await this.load.read(source.file), source.file) // step 7 + // step 7: read the file + const doc = 'doc' in source + ? source.doc + : await Doc.fromXml(await this.load.read(source.file), source.file) const target = opts.out ?? doc.file - const lint = await this.settings.lint(target, opts) // .bpmnlintrc - const result = await new EditBatchService(doc, commands, lint.applyTo(opts, target)).run(target) // step 4 - if (!opts.dryRun) result.written = await this.save.save(target, result.xml, { /* … */ }) // step 7 + + // the project's .bpmnlintrc, if any + const lint = await this.settings.lint(target, opts) + + // step 4: the domain does the work + const batchOpts = lint.applyTo(opts, target) + const batch = new EditBatchService(doc, commands, batchOpts) + const result = await batch.run(target) + + // step 7: write the file + if (!opts.dryRun) { + result.written = await this.save.save(target, result.xml, { … }) + } return result } } @@ -168,13 +205,23 @@ original text. If anything throws, nothing is written: the caller keeps its inpu ```ts // src/domain/batch/EditBatchService.ts async run(file = this.doc.file): Promise { - await this.guard() // lossless import, C7/C8 rules loaded - const findings = await FindingBaseline.capture(this.doc, …) // warnings that were already there - const diagram = await BatchDrawing.capture(this.doc, …) // the drawing before - const { batch } = await this.runCommands(diagram) // step 5 - await this.validate(findings, batch) // new errors refuse the write - const drawn = await diagram.apply(this.edit, batch.ops, batch.changes) // layout - const xml = await this.keptText(drawn.xml, …) // rewrite only what changed + // lossless import, Camunda 7 / 8 rules loaded + await this.guard() + + // remember the state before: warnings already there, the drawing + const findings = await FindingBaseline.capture(this.doc, …) + const diagram = await BatchDrawing.capture(this.doc, …) + + // step 5: run every command on the model + const { batch } = await this.runCommands(diagram) + + // new errors refuse the write + await this.validate(findings, batch) + + // lay out the drawing, rewrite only what changed in the text + const drawn = await diagram.apply(this.edit, batch.ops, batch.changes) + const xml = await this.keptText(drawn.xml, …) + return this.result(file, batch, xml, drawn.layout, …) } ``` @@ -189,11 +236,16 @@ step 4. // src/domain/batch/ModelCommandService.ts private dispatch(op: EditCommand): ChangeSet { if (isDiagramCommand(op)) return new ChangeSet() + const { doc, edit } = this switch (op.op) { - case 'add': return new ElementCreationService(this.doc, this.edit).add(op) - case 'connect': return new ConnectionService(this.doc, this.edit).connect(op) - case 'set': return new PropertyService(this.doc, this.edit).set(op) - case 'remove': return new ElementRemovalService(this.doc, this.edit).remove(op) + case 'add': + return new ElementCreationService(doc, edit).add(op) + case 'connect': + return new ConnectionService(doc, edit).connect(op) + case 'set': + return new PropertyService(doc, edit).set(op) + case 'remove': + return new ElementRemovalService(doc, edit).remove(op) // … retype, move, order, ext, split } } @@ -209,17 +261,29 @@ a `ChangeSet`. // src/domain/editing/ElementCreationService.ts add(op: AddElement): ChangeSet { const cs = new ChangeSet() - const { def, trigger } = ElementCreationService.resolveKind(op.kind) // 'userTask' → bpmn:UserTask + // 'userTask' → bpmn:UserTask + const { def, trigger } = ElementCreationService.resolveKind(op.kind) const el = this.addFlowNode(op, def, trigger, cs) - if (op.set) cs.merge(new PropertyService(this.doc, this.edit).set({ op: 'set', id: idOf(el), values: op.set })) + if (op.set) { + const values = { op: 'set', id: idOf(el), values: op.set } + cs.merge(new PropertyService(this.doc, this.edit).set(values)) + } return cs } -private addFlowNode(op: AddElement, def: KindDef, trigger: Trigger | undefined, cs: ChangeSet): El { - const id = this.edit.allocator.explicitOr(op.id, …) // 'Activity_CheckInvoice' +private addFlowNode( + op: AddElement, + def: KindDef, + trigger: Trigger | undefined, + cs: ChangeSet, +): El { + // 'Activity_CheckInvoice' + const id = this.edit.allocator.explicitOr(op.id, …) const el = this.doc.create(def.type, { id, name: op.name }) cs.create({ id, kind: def.kind, name: op.name }) - new NodePlacementService(this.doc, this.edit, op).place(el, cs) // --after: splice in, add the flow + + // --after: splice in behind the anchor and add the sequence flow + new NodePlacementService(this.doc, this.edit, op).place(el, cs) return el } ``` @@ -231,15 +295,21 @@ private addFlowNode(op: AddElement, def: KindDef, trigger: Trigger | undefined, ```ts // src/adapters/out/files/FileDocumentStore.ts -export class FileDocumentStore implements LoadDocumentPort, SaveDocumentPort { +export class FileDocumentStore + implements LoadDocumentPort, SaveDocumentPort +{ + // decoded in the encoding the XML declares read(file: string): Promise { - return readXml(file) // decoded in the encoding the XML declares + return readXml(file) } - async save(target: string, xml: string, opts: { source?: string; unchanged: boolean; backup?: boolean }) { - if (opts.unchanged && target === opts.source) return false // no-op: leave the file alone - if (opts.backup && opts.source) await FileDocumentStore.backup(opts.source) - await writeAtomic(target, xml) // a failed write leaves the old file + async save(target: string, xml: string, opts: SaveOptions) { + const { source, unchanged, backup } = opts + // a no-op leaves the file alone + if (unchanged && target === source) return false + if (backup && source) await FileDocumentStore.backup(source) + // a failed write leaves the old file + await writeAtomic(target, xml) return true } } @@ -251,7 +321,7 @@ Back in `MutationCommand`, the result becomes a report (`MutationReportBuilder`) (`MutationText`), both in `adapters/in/report/`. The library returns the same report, so the CLI and `applyToXml()` describe a change in the same words. -```text +```txt created userTask Activity_CheckInvoice "Check invoice" - after Event_OrderReceived created sequenceFlow Flow_OrderReceivedToCheckInvoice - Event_OrderReceived -> Activity_CheckInvoice layout: ok - full (engine-owned diagram: redrawn) From 215eb98c60e590614775b6179a6941a8b341d355 Mon Sep 17 00:00:00 2001 From: Marco Schaeck Date: Tue, 27 Oct 2026 12:10:59 +0100 Subject: [PATCH 6/7] docs: format code examples to the column width, ops JSON one key per line --- docs/camunda.md | 45 +++++--- docs/commands.md | 15 ++- docs/library.md | 83 ++++++++++++--- docs/ops.md | 130 +++++++++++++++++------ docs/reference.md | 5 +- docs/validators.md | 70 +++++++++--- docs/walkthrough.md | 50 ++++++--- test/adapters/out/files/bpmnlint.test.ts | 9 +- test/e2e/api.test.ts | 5 +- 9 files changed, 316 insertions(+), 96 deletions(-) diff --git a/docs/camunda.md b/docs/camunda.md index 86ec5600..f3a32d36 100644 --- a/docs/camunda.md +++ b/docs/camunda.md @@ -47,11 +47,17 @@ approves the rest. ```sh bpmn new claim.bpmn --name "Claim handling" --target camunda7 bpmn add claim.bpmn start "Claim received" -bpmn add claim.bpmn serviceTask "Check coverage" --after Event_ClaimReceived camunda:type=external camunda:topic=check-coverage -bpmn ext add claim.bpmn Activity_CheckCoverage camunda:inputParameter name=amount --body '${amount}' -bpmn add claim.bpmn boundary:error "Not covered" --on Activity_CheckCoverage --error "Not covered" --error-code NOT_COVERED +bpmn add claim.bpmn serviceTask "Check coverage" \ + --after Event_ClaimReceived camunda:type=external \ + camunda:topic=check-coverage +bpmn ext add claim.bpmn Activity_CheckCoverage camunda:inputParameter \ + name=amount --body '${amount}' +bpmn add claim.bpmn boundary:error "Not covered" \ + --on Activity_CheckCoverage --error "Not covered" \ + --error-code NOT_COVERED bpmn add claim.bpmn end "Claim rejected" --after Event_NotCovered -bpmn add claim.bpmn userTask "Approve claim" --after Activity_CheckCoverage camunda:candidateGroups=claims +bpmn add claim.bpmn userTask "Approve claim" \ + --after Activity_CheckCoverage camunda:candidateGroups=claims bpmn add claim.bpmn end "Claim settled" --after Activity_ApproveClaim ``` @@ -91,16 +97,27 @@ flow. The process then waits for the payment message. ```sh bpmn new invoice.bpmn --name "Invoice approval" --target camunda8 bpmn add invoice.bpmn start "Invoice received" -bpmn add invoice.bpmn serviceTask "Check invoice" --after Event_InvoiceReceived -bpmn ext add invoice.bpmn Activity_CheckInvoice zeebe:taskDefinition type=check-invoice retries=3 -bpmn ext add invoice.bpmn Activity_CheckInvoice zeebe:input source==invoice.amount target=amount -bpmn add invoice.bpmn exclusiveGateway "Amount over 1000?" --after Activity_CheckInvoice -bpmn add invoice.bpmn userTask "Approve invoice" --after Gateway_AmountOver1000 --flow-name yes --condition '= amount > 1000' -bpmn ext add invoice.bpmn Activity_ApproveInvoice zeebe:assignmentDefinition candidateGroups=finance -bpmn add invoice.bpmn exclusiveGateway --id Gateway_Approved --after Activity_ApproveInvoice -bpmn connect invoice.bpmn Gateway_AmountOver1000 Gateway_Approved --name no --default -bpmn add invoice.bpmn receiveTask "Wait for payment" --after Gateway_Approved --message "Payment received" -bpmn ext add invoice.bpmn Activity_WaitForPayment zeebe:subscription correlationKey==invoiceId +bpmn add invoice.bpmn serviceTask "Check invoice" \ + --after Event_InvoiceReceived +bpmn ext add invoice.bpmn Activity_CheckInvoice zeebe:taskDefinition \ + type=check-invoice retries=3 +bpmn ext add invoice.bpmn Activity_CheckInvoice zeebe:input \ + source==invoice.amount target=amount +bpmn add invoice.bpmn exclusiveGateway "Amount over 1000?" \ + --after Activity_CheckInvoice +bpmn add invoice.bpmn userTask "Approve invoice" \ + --after Gateway_AmountOver1000 --flow-name yes \ + --condition '= amount > 1000' +bpmn ext add invoice.bpmn Activity_ApproveInvoice \ + zeebe:assignmentDefinition candidateGroups=finance +bpmn add invoice.bpmn exclusiveGateway --id Gateway_Approved \ + --after Activity_ApproveInvoice +bpmn connect invoice.bpmn Gateway_AmountOver1000 Gateway_Approved \ + --name no --default +bpmn add invoice.bpmn receiveTask "Wait for payment" \ + --after Gateway_Approved --message "Payment received" +bpmn ext add invoice.bpmn Activity_WaitForPayment zeebe:subscription \ + correlationKey==invoiceId bpmn add invoice.bpmn end "Invoice settled" --after Activity_WaitForPayment ``` diff --git a/docs/commands.md b/docs/commands.md index 6b280a98..f66c8324 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -83,8 +83,10 @@ Creates one element and wires it in. A placement option says where; trailing `ke like `set`. ```sh -bpmn add order.bpmn userTask "Check invoice" --after Event_OrderReceived camunda:assignee=kermit -bpmn add order.bpmn boundary:timer "2 days" --on Activity_CheckInvoice --timer PT2D --non-interrupting +bpmn add order.bpmn userTask "Check invoice" --after Event_OrderReceived \ + camunda:assignee=kermit +bpmn add order.bpmn boundary:timer "2 days" --on Activity_CheckInvoice \ + --timer PT2D --non-interrupting ``` - Placement: `--after`, `--before`, `--flow`, `--in`, `--on`, `--lane` ([Placement grammar](reference.md#placement-grammar)). @@ -107,8 +109,10 @@ The endpoints decide the kind: sequence flow, message flow (different pools), as ## `set` ```sh -bpmn set order.bpmn Activity_CheckInvoice name="Check the invoice" camunda:asyncBefore=true -bpmn set order.bpmn Activity_CheckInvoice 'loop.camunda:collection=${invoices}' +bpmn set order.bpmn Activity_CheckInvoice name="Check the invoice" \ + camunda:asyncBefore=true +bpmn set order.bpmn Activity_CheckInvoice \ + 'loop.camunda:collection=${invoices}' ``` `key=` or `--unset key` removes a property. `id=New_Id` renames and re-points every reference. Parts @@ -144,7 +148,8 @@ A sub-process with content becomes a task only with `--force`. ```sh bpmn move order.bpmn Activity_ClarifyInvoice --before Activity_BookInvoice -bpmn order order.bpmn Gateway_InvoiceOk Flow_InvoiceOkToClarifyInvoice Flow_InvoiceOkToBookInvoice +bpmn order order.bpmn Gateway_InvoiceOk Flow_InvoiceOkToClarifyInvoice \ + Flow_InvoiceOkToBookInvoice ``` The flow order drives the picture: the default flow (else the first) continues straight, the others diff --git a/docs/library.md b/docs/library.md index ce758ccd..d9a7f92c 100644 --- a/docs/library.md +++ b/docs/library.md @@ -12,22 +12,45 @@ npm install @miragon/bpmn-cli The functions take the XML as a string and return the new XML with the result the CLI prints: ```ts -import { applyToXml, CliError, newXml, renderMutation, showXml } from '@miragon/bpmn-cli'; +import { + applyToXml, + CliError, + newXml, + renderMutation, + showXml, +} from '@miragon/bpmn-cli'; let { xml } = await newXml({ processName: 'Order handling' }); const edit = await applyToXml(xml, [ { op: 'add', kind: 'start', name: 'Order received' }, - { op: 'add', kind: 'userTask', name: 'Check invoice', after: 'Event_OrderReceived' }, + { + op: 'add', + kind: 'userTask', + name: 'Check invoice', + after: 'Event_OrderReceived', + }, { op: 'add', kind: 'end', name: 'Done', after: 'Activity_CheckInvoice' }, ]); if (!edit.unchanged) xml = edit.xml; // and save it -edit.result.created.map((c) => c.id); // ['Event_OrderReceived', 'Activity_CheckInvoice', 'Flow_OrderReceivedToCheckInvoice', 'Event_Done', 'Flow_CheckInvoiceToDone'] -edit.result.layout.mode; // 'full' (no diagram before: drawn from scratch) -renderMutation(edit.result); // the text `bpmn apply` prints: created ... / layout: ok - full (...) -await showXml(xml); // the `bpmn show` text of the README quick start + +edit.result.created.map((c) => c.id); +// ['Event_OrderReceived', 'Activity_CheckInvoice', +// 'Flow_OrderReceivedToCheckInvoice', 'Event_Done', +// 'Flow_CheckInvoiceToDone'] + +edit.result.layout.mode; +// 'full' (no diagram before: drawn from scratch) + +renderMutation(edit.result); +// the text `bpmn apply` prints: created ... / layout: ok - full (...) + +await showXml(xml); +// the `bpmn show` text of the README quick start try { - await applyToXml(xml, [{ op: 'add', kind: 'userTask', name: 'Ship', after: 'Activity_Check' }]); + await applyToXml(xml, [ + { op: 'add', kind: 'userTask', name: 'Ship', after: 'Activity_Check' }, + ]); } catch (err) { if (!(err instanceof CliError)) throw err; err.code; // 'E_NOT_FOUND' @@ -120,14 +143,22 @@ A bpmnlint configuration as an advisory validator ([Validators](validators.md#ad In Node `bpmnlintEngine()` gives the engine; a browser host bundles bpmnlint's `Linter` itself. ```ts -import { applyToXml, bpmnlintValidator, resolveBpmnlintConfig } from '@miragon/bpmn-cli'; +import { + applyToXml, + bpmnlintValidator, + resolveBpmnlintConfig, +} from '@miragon/bpmn-cli'; import { bpmnlintEngine } from '@miragon/bpmn-cli/node'; const engine = bpmnlintEngine(); -const resolved = await resolveBpmnlintConfig(engine, { extends: 'bpmnlint:recommended' }); +const resolved = await resolveBpmnlintConfig(engine, { + extends: 'bpmnlint:recommended', +}); const lint = bpmnlintValidator(engine, resolved, { detail: 'our rules' }); const edit = await applyToXml(xml, ops, { validators: [lint] }); -edit.result.warnings.added; // the bpmnlint findings the change added, an error with severity 'error' + +edit.result.warnings.added; +// the bpmnlint findings the change added, an error with severity 'error' ``` ### Declared id conventions @@ -137,11 +168,33 @@ conventions, or its lint reports bpmn-cli's new ids. A table of your own works t ([bpmnlint](bpmnlint.md#id-conventions) has the rules): ```ts -const automation = await resolveBpmnlintConfig(engine, engine.plugin.getDefaultLintConfig({ preset: 'automation' })); -const { conventions, notes } = idConventionsFromLint(automation.rules[ID_NAMING_RULE], engine.plugin.naming, { source: 'our rules' }); -const named = await applyToXml(xml, ops, { validators: [bpmnlintValidator(engine, automation, { notes })], conventions }); -named.result.conventions; // { ids: [...], source: 'our rules' }: the new ids that follow the rule, not the file's style -await applyToXml(xml, ops, { conventions: { prefixes: { 'bpmn:ServiceTask': 'serviceTask_', 'bpmn:SequenceFlow': 'flow_' }, case: 'camelCase' } }); +const automation = await resolveBpmnlintConfig( + engine, + engine.plugin.getDefaultLintConfig({ preset: 'automation' }), +); +const { conventions, notes } = idConventionsFromLint( + automation.rules[ID_NAMING_RULE], + engine.plugin.naming, + { source: 'our rules' }, +); +const named = await applyToXml(xml, ops, { + validators: [bpmnlintValidator(engine, automation, { notes })], + conventions, +}); + +named.result.conventions; +// { ids: [...], source: 'our rules' }: +// the new ids that follow the rule, not the file's style + +await applyToXml(xml, ops, { + conventions: { + prefixes: { + 'bpmn:ServiceTask': 'serviceTask_', + 'bpmn:SequenceFlow': 'flow_', + }, + case: 'camelCase', + }, +}); ``` ## Browser bundles diff --git a/docs/ops.md b/docs/ops.md index 784fac02..d99041e1 100644 --- a/docs/ops.md +++ b/docs/ops.md @@ -21,12 +21,25 @@ whenever a change touches more than one element. ```json [book.json] [ - { "op": "add", "kind": "serviceTask", "name": "Book invoice", - "after": "Activity_CheckInvoice", "as": "$book" }, - { "op": "set", "id": "$book", - "values": { "doc": "Posts the invoice to the ledger." } }, - { "op": "ext", "id": "$book", "action": "add", - "type": "zeebe:taskDefinition", "attrs": { "type": "book-invoice" } } + { + "op": "add", + "kind": "serviceTask", + "name": "Book invoice", + "after": "Activity_CheckInvoice", + "as": "$book" + }, + { + "op": "set", + "id": "$book", + "values": { "doc": "Posts the invoice to the ledger." } + }, + { + "op": "ext", + "id": "$book", + "action": "add", + "type": "zeebe:taskDefinition", + "attrs": { "type": "book-invoice" } + } ] ``` @@ -77,7 +90,14 @@ retype. On a splice `"flowAs"` names the flow into the new node and `"outAs"` th creates (new node -> old successor), so the same batch can give it a condition: ```json -{"op": "add", "kind": "exclusiveGateway", "name": "Valid?", "flow": "Flow_CheckToOk", "as": "$valid", "outAs": "$yes"} +{ + "op": "add", + "kind": "exclusiveGateway", + "name": "Valid?", + "flow": "Flow_CheckToOk", + "as": "$valid", + "outAs": "$yes" +} ``` ## Flow ids in a batch @@ -96,38 +116,86 @@ splits after the check, adds a reminder timer with its own path and gives both t ```json [ops.json] { "ops": [ - { "op": "split", "after": "Activity_CheckInvoice", - "kind": "exclusiveGateway", "name": "Invoice ok?", "as": "$ok", + { + "op": "split", + "after": "Activity_CheckInvoice", + "kind": "exclusiveGateway", + "name": "Invoice ok?", + "as": "$ok", "branches": [ - { "flowName": "yes", "condition": "= ok", + { + "flowName": "yes", + "condition": "= ok", "nodes": [ - { "kind": "serviceTask", "name": "Book invoice", "as": "$book" } - ] }, - { "flowName": "no", "default": true, + { + "kind": "serviceTask", + "name": "Book invoice", + "as": "$book" + } + ] + }, + { + "flowName": "no", + "default": true, "nodes": [ - { "kind": "userTask", "name": "Clarify invoice", "as": "$clarify", + { + "kind": "userTask", + "name": "Clarify invoice", + "as": "$clarify", "set": { "doc": "Call the customer and clarify the open positions." - } } - ] } - ] }, - { "op": "add", "kind": "boundaryEvent:timer", "name": "Reminder", - "on": "$clarify", "timer": "P2D", "nonInterrupting": true, - "as": "$reminder" }, - { "op": "add", "kind": "sendTask", "name": "Remind customer", - "after": "$reminder", "as": "$remind" }, - { "op": "add", "kind": "endEvent", "name": "Reminder sent", - "in": "Process_OrderHandling", "as": "$sent" }, + } + } + ] + } + ] + }, + { + "op": "add", + "kind": "boundaryEvent:timer", + "name": "Reminder", + "on": "$clarify", + "timer": "P2D", + "nonInterrupting": true, + "as": "$reminder" + }, + { + "op": "add", + "kind": "sendTask", + "name": "Remind customer", + "after": "$reminder", + "as": "$remind" + }, + { + "op": "add", + "kind": "endEvent", + "name": "Reminder sent", + "in": "Process_OrderHandling", + "as": "$sent" + }, { "op": "connect", "source": "$remind", "target": "$sent" }, - { "op": "set", "id": "$book", - "values": { "name": "Book invoice in ERP", - "doc": "Posts the invoice to the ledger." } }, - { "op": "ext", "id": "$book", "action": "add", + { + "op": "set", + "id": "$book", + "values": { + "name": "Book invoice in ERP", + "doc": "Posts the invoice to the ledger." + } + }, + { + "op": "ext", + "id": "$book", + "action": "add", "type": "zeebe:taskDefinition", - "attrs": { "type": "book-invoice", "retries": "3" } }, - { "op": "ext", "id": "$remind", "action": "add", + "attrs": { "type": "book-invoice", "retries": "3" } + }, + { + "op": "ext", + "id": "$remind", + "action": "add", "type": "zeebe:taskDefinition", - "attrs": { "type": "remind-customer" } } + "attrs": { "type": "remind-customer" } + } ] } ``` diff --git a/docs/reference.md b/docs/reference.md index 9c574af8..cb831407 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -147,7 +147,10 @@ written: order.bpmn ```json { - "ok": true, "file": "order.bpmn", "written": true, "unchanged": false, + "ok": true, + "file": "order.bpmn", + "written": true, + "unchanged": false, "created": [{ "id": "Event_Done", "kind": "endEvent", "name": "Done" }], "warnings": { "added": [], "resolved": [], "preexistingCount": 0 }, "layout": { "status": "ok", "mode": "full" }, diff --git a/docs/validators.md b/docs/validators.md index 74dcebb9..72a44efa 100644 --- a/docs/validators.md +++ b/docs/validators.md @@ -10,7 +10,12 @@ A house rule: a path ends in an end event. The first write would add a task with flow and is refused; the second one passes. ```ts -import { applyToXml, CliError, newXml, type NamedValidator } from '@miragon/bpmn-cli'; +import { + applyToXml, + CliError, + newXml, + type NamedValidator, +} from '@miragon/bpmn-cli'; // a house rule: a path ends in an end event, never in a task or a gateway const houseRules: NamedValidator = { @@ -20,8 +25,14 @@ const houseRules: NamedValidator = { validateDoc: (doc) => doc.processes() .flatMap((process) => doc.flowNodes(process)) - .filter((node) => node.$type !== 'bpmn:EndEvent' && doc.outgoing(node).length === 0) - .map((node) => ({ severity: 'error', code: 'DEAD_END', message: `${node.id} has no outgoing sequence flow`, element: node.id })), + .filter((node) => node.$type !== 'bpmn:EndEvent') + .filter((node) => doc.outgoing(node).length === 0) + .map((node) => ({ + severity: 'error', + code: 'DEAD_END', + message: `${node.id} has no outgoing sequence flow`, + element: node.id, + })), }; let { xml } = await newXml({ processName: 'Order handling' }); @@ -31,16 +42,39 @@ let { xml } = await newXml({ processName: 'Order handling' }); ])); try { - await applyToXml(xml, [{ op: 'add', kind: 'userTask', name: 'Archive', in: 'Process_OrderHandling' }], { validators: [houseRules] }); + const archive = { + op: 'add', + kind: 'userTask', + name: 'Archive', + in: 'Process_OrderHandling', + }; + await applyToXml(xml, [archive], { validators: [houseRules] }); } catch (err) { if (!(err instanceof CliError)) throw err; - err.code; // 'E_VALIDATION' - err.message; // 'The change would introduce 1 error(s) reported by validator house-rules; nothing was written' - err.details.errors; // [{ code: 'DEAD_END', message: 'Activity_Archive has no outgoing sequence flow', validator: 'house-rules', severity: 'error', element: 'Activity_Archive' }] + err.code; + // 'E_VALIDATION' + err.message; + // 'The change would introduce 1 error(s) reported by validator + // house-rules; nothing was written' + err.details.errors; + // [{ code: 'DEAD_END', + // message: 'Activity_Archive has no outgoing sequence flow', + // validator: 'house-rules', severity: 'error', + // element: 'Activity_Archive' }] } -const edit = await applyToXml(xml, [{ op: 'add', kind: 'userTask', name: 'Check invoice', after: 'Event_OrderReceived' }], { validators: [houseRules] }); -edit.result.validation.validators; // [{ name: 'house-rules', detail: 'our modelling guideline', errors: [], warnings: [], preexisting: [], resolved: [], counts: { errors: 0, warnings: 0 } }] +const check = { + op: 'add', + kind: 'userTask', + name: 'Check invoice', + after: 'Event_OrderReceived', +}; +const edit = await applyToXml(xml, [check], { validators: [houseRules] }); + +edit.result.validation.validators; +// [{ name: 'house-rules', detail: 'our modelling guideline', +// errors: [], warnings: [], preexisting: [], resolved: [], +// counts: { errors: 0, warnings: 0 } }] ``` ## Writing a validator @@ -87,14 +121,26 @@ A validator with `blocks: false` reports and never refuses. The project's bpmnli ([bpmnlint](bpmnlint.md)). A host can run the plugin's default configuration the same way: ```js -import { applyToXml, bpmnlintValidator, idConventionsFromLint, ID_NAMING_RULE, resolveBpmnlintConfig } from '@miragon/bpmn-cli'; +import { + applyToXml, + bpmnlintValidator, + idConventionsFromLint, + ID_NAMING_RULE, + resolveBpmnlintConfig, +} from '@miragon/bpmn-cli'; import { bpmnlintEngine } from '@miragon/bpmn-cli/node'; const engine = bpmnlintEngine(); const rc = engine.plugin.getDefaultLintConfig({ engine: 'c7' }); const resolved = await resolveBpmnlintConfig(engine, rc); -const { conventions, notes } = idConventionsFromLint(resolved.rules[ID_NAMING_RULE], engine.plugin.naming); -const lint = bpmnlintValidator(engine, resolved, { detail: 'default rules', notes }); +const { conventions, notes } = idConventionsFromLint( + resolved.rules[ID_NAMING_RULE], + engine.plugin.naming, +); +const lint = bpmnlintValidator(engine, resolved, { + detail: 'default rules', + notes, +}); await applyToXml(xml, ops, { validators: [lint], conventions }); ``` diff --git a/docs/walkthrough.md b/docs/walkthrough.md index e38de872..ec3c3825 100644 --- a/docs/walkthrough.md +++ b/docs/walkthrough.md @@ -43,20 +43,42 @@ a gateway with its branches and the join; an alias (`"as": "$book"`) names a new ops. ```json [ops.json] -{ "ops": [ - { "op": "split", "after": "Activity_CheckInvoice", "name": "Invoice ok?", - "branches": [ - { "flowName": "yes", "condition": "= ok", - "nodes": [ - { "kind": "serviceTask", "name": "Book invoice", "as": "$book" } - ] }, - { "flowName": "no", "default": true, - "nodes": [ - { "kind": "sendTask", "name": "Ask customer", "as": "$ask" } - ] } ] }, - { "op": "ext", "id": "$book", "action": "add", - "type": "zeebe:taskDefinition", "attrs": { "type": "book-invoice" } } -] } +{ + "ops": [ + { + "op": "split", + "after": "Activity_CheckInvoice", + "name": "Invoice ok?", + "branches": [ + { + "flowName": "yes", + "condition": "= ok", + "nodes": [ + { + "kind": "serviceTask", + "name": "Book invoice", + "as": "$book" + } + ] + }, + { + "flowName": "no", + "default": true, + "nodes": [ + { "kind": "sendTask", "name": "Ask customer", "as": "$ask" } + ] + } + ] + }, + { + "op": "ext", + "id": "$book", + "action": "add", + "type": "zeebe:taskDefinition", + "attrs": { "type": "book-invoice" } + } + ] +} ``` ```console{4,5} diff --git a/test/adapters/out/files/bpmnlint.test.ts b/test/adapters/out/files/bpmnlint.test.ts index e480930b..d9e87971 100644 --- a/test/adapters/out/files/bpmnlint.test.ts +++ b/test/adapters/out/files/bpmnlint.test.ts @@ -83,6 +83,9 @@ const listless = (xml: string): string => xml.replace(/\s* (issues as ValidatorIssue[]).filter((warning) => warning.validator === 'bpmnlint'); const codes = (issues: readonly unknown[]): string[] => (issues as Array<{ code: string; element?: string }>).map((warning) => `${warning.code}${warning.element ? ` ${warning.element}` : ''}`); +/** A code example as written in the docs, without line breaks, indentation, comment markers or trailing commas. */ +const codeOf = (text: string): string => text.replace(/\/\/|\s+/g, '').replace(/,(?=[)\]}])/g, ''); + describe('resolveBpmnlintConfig: the effective rules', () => { it('normalises the names, keeps the options as bpmnlint hands them to the rule, runs any flag but off as a warning', async () => { const engine = bpmnlintEngine(); @@ -1702,7 +1705,7 @@ describe('the library examples (docs/library.md, docs/validators.md)', () => { "const resolved = await resolveBpmnlintConfig(engine, { extends: 'bpmnlint:recommended' });", "const lint = bpmnlintValidator(engine, resolved, { detail: 'our rules' });", 'const edit = await applyToXml(xml, ops, { validators: [lint] });', - ]) expect(library).toContain(line); + ]) expect(codeOf(library)).toContain(codeOf(line)); const engine = bpmnlintEngine(); const resolved = await resolveBpmnlintConfig(engine, { extends: 'bpmnlint:recommended' }); const lint = bpmnlintValidator(engine, resolved, { detail: 'our rules' }); @@ -1716,7 +1719,7 @@ describe('the library examples (docs/library.md, docs/validators.md)', () => { 'const { conventions, notes } = idConventionsFromLint(resolved.rules[ID_NAMING_RULE], engine.plugin.naming);', "const lint = bpmnlintValidator(engine, resolved, { detail: 'default rules', notes });", 'await applyToXml(xml, ops, { validators: [lint], conventions });', - ]) expect(validators).toContain(line); + ]) expect(codeOf(validators)).toContain(codeOf(line)); for (const target of ['c7', 'c8'] as const) { const rc = engine.plugin.getDefaultLintConfig({ engine: target }); const resolved = await resolveBpmnlintConfig(engine, rc); @@ -1746,7 +1749,7 @@ describe('the library examples (docs/library.md, docs/validators.md)', () => { "const { conventions, notes } = idConventionsFromLint(automation.rules[ID_NAMING_RULE], engine.plugin.naming, { source: 'our rules' });", 'const named = await applyToXml(xml, ops, { validators: [bpmnlintValidator(engine, automation, { notes })], conventions });', "await applyToXml(xml, ops, { conventions: { prefixes: { 'bpmn:ServiceTask': 'serviceTask_', 'bpmn:SequenceFlow': 'flow_' }, case: 'camelCase' } });", - ]) expect(library).toContain(line); + ]) expect(codeOf(library)).toContain(codeOf(line)); const engine = bpmnlintEngine(); const automation = await resolveBpmnlintConfig(engine, engine.plugin.getDefaultLintConfig({ preset: 'automation' })); const { conventions, notes } = idConventionsFromLint(automation.rules[ID_NAMING_RULE], engine.plugin.naming, { source: 'our rules' }); diff --git a/test/e2e/api.test.ts b/test/e2e/api.test.ts index 6de52a47..de75ea9c 100644 --- a/test/e2e/api.test.ts +++ b/test/e2e/api.test.ts @@ -49,6 +49,9 @@ interface Run { err: string; } +/** A code example as written in the docs, without line breaks, indentation, comment markers or trailing commas. */ +const codeOf = (text: string): string => text.replace(/\/\/|\s+/g, '').replace(/,(?=[)\]}])/g, ''); + function cli(...args: string[]): Run { const spawned = spawnSync(process.execPath, ['--import', 'tsx', join(ROOT, 'src', 'cli.ts'), ...args], { encoding: 'utf8', cwd: ROOT, input: '' }); return { code: spawned.status ?? -1, out: spawned.stdout, err: spawned.stderr }; @@ -84,7 +87,7 @@ describe('the docs/library.md example (Library use)', () => { expect(example).toContain("{ op: 'add', kind: 'end', name: 'Done', after: 'Activity_CheckInvoice' },"); if (!edit.unchanged) xml = edit.xml; const quote = (data: unknown): string => JSON.stringify(data).replaceAll('"', "'").replaceAll(',', ', '); - expect(example).toContain(`// ${quote(edit.result.created.map((change) => change.id))}`); + expect(codeOf(example)).toContain(codeOf(quote(edit.result.created.map((change) => change.id)))); expect(example).toContain(`// '${edit.result.layout.mode}' (${edit.result.layout.reason})`); expect(renderMutation(edit.result)).toMatch(/^created startEvent [\s\S]*\nlayout: ok - full \(/m); const quickStart = /\$ bpmn show order\.bpmn\n([\s\S]*?)\n```/.exec(readme)?.[1]; From 7aaa1e61fc8c521585dd07b2ead2ba14e1e32ff4 Mon Sep 17 00:00:00 2001 From: Marco Schaeck Date: Tue, 27 Oct 2026 12:28:52 +0100 Subject: [PATCH 7/7] docs(architecture): call the use case by its new name editDocument --- docs/architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index c192d6c7..7c79498d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -131,7 +131,7 @@ protected async mutate( ops: EditCommand[], opts: MutationFlags, ) { - const result = await this.cli.useCases.edit.edit({ + const result = await this.cli.useCases.editDocument.edit({ ...opts, source: { file }, commands: ops,