diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 683da86a..9806cbe8 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -40,8 +40,8 @@ them on a private corpus without copying it into the repository, are described i
- `src/`: the four entries in the root and three rings around the domain (ports and adapters).
The entries wire the rings: `cli.ts` starts the `Cli` with the file adapters, `node.ts` is
- `@miragon/bpmn-cli/node`, `api.ts` the in-memory functions (the only place they wire the services to the in-memory adapters; `index.ts` re-exports them), and `index.ts` the browser-safe
- core. `src/adapters/in/report/CommandCatalogue.ts` lists every command with its arguments,
+ `@miragon/bpmn-cli/node`, `api.ts` the in-memory functions (they wire the services to the in-memory adapters; `index.ts` re-exports them), and `index.ts` the browser-safe
+ core, which wires the services of its own functions to the in-memory adapters too. `src/adapters/in/report/CommandCatalogue.ts` lists every command with its arguments,
its `--help` text and its guide usage, and the options of every write: the commander wiring in
`src/adapters/in/cli/` and `bpmn guide` both read it. `src/adapters/in/report/guide.ts` holds the
error catalogue and the other hand-written tables that `bpmn guide` (`AgentGuide.ts`) and
@@ -51,36 +51,37 @@ them on a private corpus without copying it into the repository, are described i
| --- | --- |
| `domain/document/` | the moddle tree, kinds, `Doc`, the `ChangeSet` of a mutation, the `FlowRules`, an edit's `IdAllocator`, the errors; `ids/` (id words, the file's id style, the declared id conventions), `xml/` (the text-preserving write, the encoding), `vendor/` (Camunda 7 / 8 knowledge) |
| `domain/commands/` | the commands a caller can ask for (`EditCommand`: `AddElement`, `PlaceShapes`, ...), the ops JSON of `apply` and its aliases |
- | `domain/diagram/` | the drawing (BPMN DI): `di/` reads and writes planes, frames, groups and the checkpoint every engine shares, `layout/` holds the engines (`clean/` redraws with `FullLayout`, `incremental/` keeps a hand-made drawing), `DrawnDiagram` is the drawing the diagram commands change, `DiagramCommandService` runs them and `format/` holds the services that change it (`ShapePlacementService`, `SpaceInsertionService`, ...); `BatchDrawing` is all a batch does to the drawing |
- | `domain/editing/` | the services that change the model (`ElementCreationService`, `ElementMoveService`, `PropertyService`, ...; `flows/`, `events/`, `extensions/`, `participants/`) |
- | `domain/validation/` | structural validation (`StructuralValidator`, `FlowOrder`), the validator hook, the bpmnlint validator, the `FindingBaseline` a write compares with; `profiles/` holds the engine rules that load on demand |
+ | `domain/diagram/` | the drawing (BPMN DI): `di/` reads and writes planes, frames, groups and the checkpoint every engine shares, `layout/` holds the engines (`clean/` redraws with `FullLayout`, `incremental/` keeps a hand-made drawing), `DrawnDiagram` is the drawing the diagram commands change, `commands/` holds one handler per diagram command (`ShapePlacement`, `ShapeAlignment`, `SpaceInsertion`, ...) and `DiagramCommands`, which dispatches them; `BatchDrawing` is all a batch does to the drawing |
+ | `domain/editing/` | what changes the model, one folder per concern: `elements/` (add, remove, retype, move, order, placement, artifacts, pools, lanes), `connections/` (connect, associations, sequence flows, bridges, flow ids, split), `properties/` (set, value coercion, the set keys, nested slots, vendor elements, decision links), `events/` (triggers, event definitions, root elements, the event-gateway rule), `extensions/` (ext), `ids/` (id renames); a command is a class created with the `EditSession` (`new ElementCreation(edit).add(op)`, `new Connections(edit).connect(op)`, ...) |
+ | `domain/validation/` | structural validation (`StructuralValidator`, `FlowOrder`), the validator hook, the `FindingBaseline` a write compares with and the `FindingMatcher` that tells the same finding before and after an edit; `profiles/` holds the engine rules that load on demand |
| `domain/views/` | the read views (`show`, `find`, `--around`, `--context`) and `render/`, the SVG of a diagram |
- | `domain/batch/` | one batch of commands on a document in memory: `EditBatchService` (guard, commands, validation, layout, kept text, result), `ModelCommandService` hands each model command to its domain service and `BatchDrawing` (`domain/diagram/`) lays the drawing out between them; the top of the domain, nothing imports it back |
- | `application/port/in/` | one contract per use case, the way in: an interface with its `Command` and `Result` (`EditDocumentUseCase`, `RelayoutDiagramUseCase`, `ValidateDocumentUseCase`, `ShowModelUseCase`, ...) |
- | `application/port/out/` | what the application needs from outside, named after what it does: `LoadDocumentPort`, `SaveDocumentPort`, `LoadProjectSettingsPort` (the `.bpmnlintrc`), `WriteImagePort` |
- | `application/service/` | one service per use case that implements its contract (`EditDocumentService implements EditDocumentUseCase`): load, let the domain work, save, all through the outbound ports |
- | `adapters/in/cli/` | the driving adapter: the `Cli` (commander, streams, exit codes, errors) and one class per command; it knows the use cases only by their contracts |
+ | `domain/batch/` | one batch of commands on a document in memory: `EditBatch` (guard, commands, validation, layout, kept text, result), `ModelCommands` hands each model command to its domain service and `BatchDrawing` (`domain/diagram/`) lays the drawing out between them; the top of the domain, nothing imports it back |
+ | `application/port/in/` | one contract per use case, the way in: an interface with its `Command` and `Result` (`EditDocumentUseCase`, `RelayoutDiagramUseCase`, `ValidateDocumentUseCase`, `ShowModelUseCase`, ...), also for a pure query that needs no outbound port |
+ | `application/port/out/` | what the application needs from outside, named after what it does: `DocumentPort` (load a document, resolve where a result goes, save it), `LoadProjectSettingsPort` (the `.bpmnlintrc`), `WriteImagePort` |
+ | `application/service/` | one service per use case that implements its contract (`EditDocumentService implements EditDocumentUseCase`): load, let the domain work, save, all through the outbound ports; a query asks the domain and returns its answer |
+ | `adapters/in/cli/` | the driving adapter: the `Cli` (commander, streams, exit codes, errors) and one class per command; it knows the use cases only by their contracts and reaches the domain only through them; from the domain it takes the command vocabulary to check its flags (errors, op rules, flag values) |
| `adapters/in/report/` | what a use case's result looks like to the one who asked: text and JSON reports (`MutationText`, `MutationReportBuilder`, `ValidationText`, ...), the views as text, and the reference the guide and `bpmn kinds` print; the CLI prints them and `index.ts` / `api.ts` return them |
- | `adapters/out/files/` | the driven adapters on disk, Node only: `FileDocumentStore`, `BpmnlintProjectSettings`, `FileImageWriter` (PNG) |
+ | `adapters/out/bpmnlint/` | the injected bpmnlint as a validator of the domain, browser-safe: `BpmnlintConfigResolution` (the effective `.bpmnlintrc`), `BpmnlintValidator`, `IdConventionsReader` (the id conventions of `element-id-naming`) |
+ | `adapters/out/files/` | the driven adapters on disk, Node only: `FileDocumentStore`, `BpmnlintProjectSettings` (loads the `.bpmnlintrc` and its plugins for `adapters/out/bpmnlint/`), `FileImageWriter` (PNG) |
+ | `adapters/out/document/` | `DocumentStore`, what the file store and the in-memory store share behind the `DocumentPort`: a source parsed into a `Doc`, the lossless check, the target of a write and whether to write; browser-safe |
| `adapters/out/memory/` | the driven adapters of the in-memory API: `NoDocumentFiles`, `NoProjectSettings` |
| `types/` | type shims for the bpmn-io packages that ship no usable declarations |
- Where new code goes: a new command is a type in `domain/commands/` and a `...Service` of the
- domain that `domain/batch/` hands it to; a new command line verb or API function is a contract in
+ Where new code goes: a new command is a type in `domain/commands/` and a class of
+ `domain/editing/` created with the `EditSession` that `domain/batch/` hands it; a new command line verb or API function is a contract in
`application/port/in/` and a service that implements it; reading the model without changing
it is a view (`domain/views/`); deciding positions is `domain/diagram/`; text or JSON for the
user is `adapters/in/report/`; anything that touches the file system or `process` is an
adapter behind a port of `application/port/out/`; mapping flags to options and printing a
- command's result is `adapters/in/cli/`. The entries (`cli.ts`, `node.ts`, `api.ts`) create
+ command's result is `adapters/in/cli/`. The entries (`cli.ts`, `index.ts`, `node.ts`, `api.ts`) create
the services with their adapters. Every public function that works on a document goes
- through a use case, even one that only forwards to the domain: the library entries
- (`index.ts`, `api.ts`, `node.ts`) import values only from `application/` and `adapters/`, and
- from `domain/` only types, re-exporting just the public classes, constants and pure helpers
- the test lists. `test/architecture.test.ts` checks the rings: the domain
+ through a use case, even one that only forwards to the domain: the entries import values only
+ from `application/` and `adapters/`, and from `domain/` only types, re-exporting just the
+ public classes, constants and pure helpers the test lists. `test/architecture.test.ts` checks the rings: the domain
imports neither the application nor the adapters, the application no adapter, an adapter no
- other adapter (the CLI only the reports), the driving adapter no service, only adapters and entries import `node:*`, the
- domain modules import each other without a cycle, and every service of `application/service/`
- implements its contract and calls no other, and the entries reach the domain only through the
+ other adapter (the CLI only the reports, the files only bpmnlint and the document store, the memory adapters only the document store), the driving adapter no service, only adapters and entries import `node:*`, the
+ domain modules import each other without a cycle, `application/service/` holds only services,
+ each implementing its contract and using no other class of that folder, and the entries reach the domain only through the
use cases.
**Classes first, helpers in `utils/`.** Each module builds on classes, one per file, the file
@@ -117,8 +118,7 @@ them on a private corpus without copying it into the repository, are described i
### Dependencies
-`bpmn-moddle` (the semantic model; `moddle` for its types), `bpmn-auto-layout` (pinned to
-`2.0.0-alpha.2`, loaded only for `--engine auto`), `@resvg/resvg-wasm` (the PNG rasteriser of
+`bpmn-moddle` (the semantic model; `moddle` for its types), `@resvg/resvg-wasm` (the PNG rasteriser of
`bpmn render`), `commander` (the CLI only), and `bpmnlint` (pinned to `11.15.0`) with
`@miragon/bpmnlint-plugin-rules` (pinned to `0.12.1`: bpmnlint's rules, camunda-compat and
Miragon's rules behind one resolver), loaded only when a `.bpmnlintrc` applies (see
diff --git a/biome.json b/biome.json
new file mode 100644
index 00000000..2bb22581
--- /dev/null
+++ b/biome.json
@@ -0,0 +1,19 @@
+{
+ "$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
+ "files": { "includes": ["src/**/*.ts"] },
+ "formatter": { "enabled": false },
+ "assist": { "enabled": false },
+ "linter": {
+ "enabled": true,
+ "rules": {
+ "recommended": false,
+ "complexity": {
+ "noExcessiveCognitiveComplexity": { "level": "warn", "options": { "maxAllowedComplexity": 25 } },
+ "noExcessiveLinesPerFunction": { "level": "warn", "options": { "maxLines": 80 } }
+ },
+ "style": {
+ "noNestedTernary": "warn"
+ }
+ }
+ }
+}
diff --git a/docs/architecture.md b/docs/architecture.md
index a0a921e1..53df2da4 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -9,9 +9,11 @@ goes are in [CONTRIBUTING.md](https://github.com/Miragon/bpmn-cli/blob/main/CONT
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).
+or `process`. Around it, the **application** offers one use case per thing a caller can do,
+also a pure query (`show`, `find`, `metrics`, an SVG in memory) that needs no outbound port.
+At the edge, **adapters** translate between the outside world and those use cases; neither the
+CLI nor the library skips the use case to call the domain. This is a hexagonal architecture
+(ports and adapters).
```mermaid
flowchart TB
@@ -24,20 +26,28 @@ flowchart TB
service --> portOut{{"Port
application/port/out"}}
portOut --> files["Files
adapters/out/files"]
portOut --> memory["No project, no files
adapters/out/memory"]
+ files --> store["Loading and saving a document
adapters/out/document"]
+ memory --> store
+ files --> bpmnlint["bpmnlint
adapters/out/bpmnlint"]
+ lib --> bpmnlint
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 cli,files,memory,store,bpmnlint adapter
class portIn,service,portOut app
class domain core
```
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
+The entries in the root of `src/` (`cli.ts`, `index.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.
+`adapters/out/bpmnlint/` wraps an injected bpmnlint as a `Validator` of the domain. The file
+adapters hand it the `.bpmnlintrc` they load; `index.ts` hands it to hosts that bring their own
+bpmnlint. `adapters/out/document/` is what the file store and the in-memory store share behind
+the `DocumentPort`: a source parsed into a `Doc`, the target of a write, the save.
| rule (checked by the test) | why |
| --- | --- |
@@ -45,7 +55,9 @@ container.
| `application/` imports no adapter | a service does not know whether it writes to disk or does not write at all |
| 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 |
+| the entries import only types from `domain/` and re-export only the public classes and constants the test lists | the entries reach the domain only through the use cases, so the CLI and the library run the same way in |
+| an adapter imports another only where the test names it (the CLI the reports, the files bpmnlint, the files and the memory store the shared document store) | the entries wire the adapters |
+| `application/service/` holds only services; each implements one use case and uses no other class of `application/service/` | one way in, no hidden orchestration |
## The journey of `bpmn add`
@@ -55,7 +67,7 @@ bpmn add order.bpmn userTask "Check invoice" --after Event_OrderReceived
```mermaid
flowchart TB
- user(["bpmn add"]) --> s1["1 · AddElementCommand"] --> s2{{"2 · EditDocumentUseCase"}} --> s3["3 · EditDocumentService"] --> s4["4 · EditBatchService"] --> s5["5 · ModelCommandService"] --> s6["6 · ElementCreationService"]
+ user(["bpmn add"]) --> s1["1 · AddElementCommand"] --> s2{{"2 · EditDocumentUseCase"}} --> s3["3 · EditDocumentService"] --> s4["4 · EditBatch"] --> s5["5 · ModelCommands"] --> s6["6 · ElementCreation"]
s3 <-- "read · write" --> s7[("7 · FileDocumentStore")]
s3 -- result --> s8["8 · MutationText"]
@@ -101,14 +113,14 @@ export class AddElementCommand extends MutationCommand {
) {
const opts = this.mutationOptions(options)
this.checkPlacementFlags(options)
- const op: AddElement = {
+ const command: AddElement = {
op: 'add',
kind, // 'userTask'
name, // 'Check invoice'
after: options['after'], // 'Event_OrderReceived'
// …
}
- await this.mutate(file, [op], opts)
+ await this.mutate(file, [command], opts)
}
}
```
@@ -126,13 +138,13 @@ behind it, `cli.ts` decided at startup.
// src/adapters/in/cli/MutationCommand.ts
protected async mutate(
file: string,
- ops: EditCommand[],
+ commands: EditCommand[],
opts: MutationFlags,
) {
const result = await this.cli.useCases.editDocument.edit({
...opts,
source: { file },
- commands: ops,
+ commands,
})
// step 8
await this.printMutation(result, opts, this.cli.io.stdout)
@@ -156,25 +168,25 @@ export declare namespace EditDocumentUseCase {
### 3. Load, run, save
The service is short on purpose: get the document, let the domain work, write the result.
-`settings`, `load` and `save` are outbound ports, handed in by the constructor. The call into
-the domain stands on a line of its own; reading and writing the file are small steps around it.
+`settings` and `documents` are outbound ports, handed in by the constructor. The call into
+the domain stands on a line of its own; reading and writing the file are calls of the port
+around it.
```ts
// src/application/service/EditDocumentService.ts
export class EditDocumentService implements EditDocumentUseCase {
constructor(
private readonly settings: LoadProjectSettingsPort,
- private readonly load: LoadDocumentPort,
- private readonly save: SaveDocumentPort,
+ private readonly documents: DocumentPort,
) {}
async edit(command: EditDocumentUseCase.Command) {
const { source, commands, ...opts } = command
- const doc = await this.open(source, opts) // step 7: read the file
- const target = await this.targetOf(doc, opts)
+ const doc = await this.documents.load(source, { ...opts, lossless: true }) // step 7: read the file
+ const target = await this.documents.target(doc.file, opts)
const project = (await this.settings.lint(opts.file ?? target, opts)).applyTo(opts, target)
- const result = await new EditBatchService(doc, commands, project).run(target) // step 4: the domain
- await this.write(result, doc, target, opts) // step 7: write the file
+ const result = await new EditBatch(doc, commands, project).run(target) // step 4: the domain
+ await this.documents.save(result, doc, target, opts) // step 7: write the file
return result
}
}
@@ -186,12 +198,12 @@ or written.
### 4. The batch
-The domain starts here. `EditBatchService` runs all commands of one call on the `Doc` in
+The domain starts here. `EditBatch` 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.
```ts
-// src/domain/batch/EditBatchService.ts
+// src/domain/batch/EditBatch.ts
async run(file = this.doc.file): Promise {
// lossless import, Camunda 7 / 8 rules loaded
await this.guard()
@@ -204,10 +216,10 @@ async run(file = this.doc.file): Promise {
const { batch } = await this.runCommands(diagram)
// new errors refuse the write
- await this.validate(findings, batch)
+ await this.validateAndDropProfileDuplicates(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 drawn = await diagram.apply(this.edit, batch.commands, batch.changes)
const xml = await this.keptText(drawn.xml, …)
return this.result(file, batch, xml, drawn.layout, …)
@@ -216,24 +228,23 @@ async run(file = this.doc.file): Promise {
### 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
+`ModelCommands` runs the commands in order and hands each one to the class that knows
+it, created with the `EditSession`: the `Doc` and what the running batch recorded so far. Commands that only move shapes (`place`, `align`, `color`, …) are left for the layout in
step 4.
```ts
-// src/domain/batch/ModelCommandService.ts
-private dispatch(op: EditCommand): ChangeSet {
- if (isDiagramCommand(op)) return new ChangeSet()
- const { doc, edit } = this
- switch (op.op) {
+// src/domain/batch/ModelCommands.ts
+private dispatch(command: EditCommand): ChangeSet {
+ if (isDiagramCommand(command)) return new ChangeSet()
+ switch (command.op) {
case 'add':
- return new ElementCreationService(doc, edit).add(op)
+ return new ElementCreation(this.edit).add(command)
case 'connect':
- return new ConnectionService(doc, edit).connect(op)
+ return new Connections(this.edit).connect(command)
case 'set':
- return new PropertyService(doc, edit).set(op)
+ return new Properties(this.edit).set(command)
case 'remove':
- return new ElementRemovalService(doc, edit).remove(op)
+ return new ElementRemoval(this.edit).remove(command)
// … retype, move, order, ext, split
}
}
@@ -241,61 +252,77 @@ private dispatch(op: EditCommand): ChangeSet {
### 6. The model changes
-`ElementCreationService` makes the new element in the bpmn-moddle tree of the `Doc`, places it
+`ElementCreation` 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`.
```ts
-// src/domain/editing/ElementCreationService.ts
-add(op: AddElement): ChangeSet {
- const cs = new ChangeSet()
- // 'userTask' → bpmn:UserTask
- const { def, trigger } = ElementCreationService.resolveKind(op.kind)
- const el = this.addFlowNode(op, def, trigger, cs)
- if (op.set) {
- const values = { op: 'set', id: idOf(el), values: op.set }
- cs.merge(new PropertyService(this.doc, this.edit).set(values))
+// src/domain/editing/elements/ElementCreation.ts
+export class ElementCreation {
+ constructor(private readonly edit: EditSession) {}
+
+ add(command: AddElement): ChangeSet {
+ const cs = new ChangeSet()
+ // 'userTask' → bpmn:UserTask
+ const { def, trigger } = parseKindToAdd(command.kind)
+ const el = this.addFlowNode(command, def, trigger, cs)
+ if (command.set) {
+ const values = { op: 'set', id: idOf(el), values: command.set }
+ cs.merge(new Properties(this.edit).set(values))
+ }
+ return cs
}
- return cs
-}
-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 })
-
- // --after: splice in behind the anchor and add the sequence flow
- new NodePlacementService(this.doc, this.edit, op).place(el, cs)
- return el
+ private addFlowNode(
+ command: AddElement,
+ def: KindDef,
+ trigger: Trigger | undefined,
+ cs: ChangeSet,
+ ): El {
+ // 'Activity_CheckInvoice'
+ const id = this.edit.allocator.explicitOr(command.id, …)
+ const el = this.edit.doc.create(def.type, { id, name: command.name })
+ cs.create({ id, kind: def.kind, name: command.name })
+
+ // --after: splice in behind the anchor and add the sequence flow
+ new NodePlacement(this.edit, command).place(el, cs)
+ return el
+ }
}
```
### 7. The file
-`FileDocumentStore` implements both file ports. It is the only class on this path that imports
-`node:fs`.
+`FileDocumentStore` implements the `DocumentPort`. What it shares with the in-memory
+`NoDocumentFiles` (parsing a source into a `Doc`, the lossless check, the target of a write,
+whether to write at all) is their base class `DocumentStore` in `adapters/out/document/`; the
+file store only adds the bytes on disk. It is the only class on this path that imports `node:fs`.
```ts
+// src/adapters/out/document/DocumentStore.ts
+export abstract class DocumentStore implements DocumentPort {
+ async load(source, opts = {}) {
+ const doc = await this.parse(source) // a { doc }, its XML or the bytes of a file
+ if (opts.lossless) doc.assertLossless(opts)
+ return doc
+ }
+
+ async save(result, doc, target, opts) {
+ if (!opts.dryRun && target) result.written = await this.write(target, result.xml, …)
+ }
+}
+
// src/adapters/out/files/FileDocumentStore.ts
-export class FileDocumentStore
- implements LoadDocumentPort, SaveDocumentPort
-{
+export class FileDocumentStore extends DocumentStore {
// decoded in the encoding the XML declares
- read(file: string): Promise {
+ protected read(file: string): Promise {
return readXml(file)
}
- async save(target: string, xml: string, opts: SaveOptions) {
- const { source, unchanged, backup } = opts
+ protected async write(target: string, xml: string, opts: WriteOptions) {
// a no-op leaves the file alone
- if (unchanged && target === source) return false
- if (backup && source) await FileDocumentStore.backup(source)
+ if (opts.unchanged && inPlace) return false
+ if (opts.backup && opts.source) await FileDocumentStore.backup(opts.source)
// a failed write leaves the old file
await writeAtomic(target, xml)
return true
@@ -342,6 +369,6 @@ flowchart LR
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
+A new command therefore needs a definition, a `case` in `ModelCommands` and a class of
+`domain/editing/` created with the `EditSession`. `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/layout.md b/docs/layout.md
index e74362fb..34527241 100644
--- a/docs/layout.md
+++ b/docs/layout.md
@@ -103,8 +103,8 @@ written: order.bpmn
## Layout conventions of the built-in engine
-The default engine (`--engine clean`) draws every diagram the same way, so you can predict the
-picture from the model.
+The built-in engine draws every diagram the same way, so you can predict the picture from the
+model.
| element | where it goes |
| --- | --- |
diff --git a/docs/library.md b/docs/library.md
index d9a7f92c..1e9f0b58 100644
--- a/docs/library.md
+++ b/docs/library.md
@@ -14,7 +14,7 @@ The functions take the XML as a string and return the new XML with the result th
```ts
import {
applyToXml,
- CliError,
+ BpmnError,
newXml,
renderMutation,
showXml,
@@ -52,13 +52,13 @@ try {
{ op: 'add', kind: 'userTask', name: 'Ship', after: 'Activity_Check' },
]);
} catch (err) {
- if (!(err instanceof CliError)) throw err;
+ if (!(err instanceof BpmnError)) throw err;
err.code; // 'E_NOT_FOUND'
err.details.candidates; // ['Activity_CheckInvoice']
}
```
-A failure throws a `CliError` (`code`, `details`, `toJSON()` for the CLI's `--json` error). Nothing
+A failure throws a `BpmnError` (`code`, `details`, `toJSON()` for the CLI's `--json` error). Nothing
is half done: the caller still has its input.
## Two entries
@@ -95,7 +95,6 @@ the CLI prints with `--json`. `renderMutation(result)` gives the CLI's text.
| option | values | CLI |
| --- | --- | --- |
| `layout` | `'auto'` (default), `'incremental'`, `'full'`, `false` | `--layout`, `--no-layout` |
-| `engine` | `'clean'` (default), `'auto'` | `--engine` |
| `force` | write despite a guard | `--force` |
| `platform` | `'auto'`, `'c7'`, `'c8'`, `'none'` | `--platform` |
| `keepFlowIds` | keep flow ids; `W_FLOW_ID_STALE` names each stale one | `--keep-flow-ids` |
@@ -104,11 +103,6 @@ the CLI prints with `--json`. `renderMutation(result)` gives the CLI's text.
The full signatures ship as TypeScript types with the package.
-::: warning engine: 'auto' and top-level await
-`engine: 'auto'` loads bpmn-auto-layout on demand. A host bundled with Rollup or Vite must not
-await such a call at the top level of its entry module (it hangs): call it from a function.
-:::
-
## File helpers (`@miragon/bpmn-cli/node`) {#file-helpers-miragon-bpmn-cli-node}
| helper | runs |
diff --git a/docs/testing.md b/docs/testing.md
index 880dea70..6472b830 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -123,8 +123,10 @@ Every fixture must be synthetic. To keep a defect found on a private model,
rebuild a minimal model that shows it (the minimiser's repro tells you which
ops matter) and add that.
-`test/adapters/out/files/bpmnlint.test.ts` checks the project's bpmnlint configuration (issue
-#33) with the real bpmnlint and @miragon/bpmnlint-plugin-rules, offline:
+The tests of the project's bpmnlint configuration (issue #33) run with the real bpmnlint and
+@miragon/bpmnlint-plugin-rules, offline: `test/adapters/out/bpmnlint/` the effective rules and
+the advisory validator, `test/adapters/out/files/bpmnlint.test.ts` the lookup, the loading and
+the resolver chain, `test/e2e/bpmnlint-cli.test.ts` the CLI;
each case writes a repository of its own into a temporary directory (a
`.git` directory, a `.bpmnlintrc`, models, and a `node_modules` with
project plugins where the case needs them), except the cases about a model
@@ -133,7 +135,7 @@ directory, a link to `/`, a hard link to a gitfile or to `/etc/hosts` where
the system allows one; the `.bpmnlintrc` applies, the plugin must not run)
mock the uid (skipped as root and on Windows), and the case of a system
without uids (Windows: only a repository inside the home directory runs
-plugins) mocks `process.getuid` away and sets `HOME`. Its CLI cases build
+plugins) mocks `process.getuid` away and sets `HOME`. The CLI cases build
`src/` with tsc into a temporary directory and run it under plain node,
which loads bpmnlint and the plugin as the published package does
(vitest's module loader would hide a load failure); they also run as root
@@ -170,7 +172,7 @@ named after it (also in the entries of an op before its retype), an id
freed and then claimed explicitly, the `conventions`
of a result (none in a new file or one that follows the rule), the view of
`show --conventions` with its reasons).
-`test/adapters/out/files/bpmnlint.test.ts` checks the lookup, the notes in validate and writes
+`test/adapters/out/files/bpmnlint.test.ts` and `test/e2e/bpmnlint-cli.test.ts` check the lookup, the notes in validate and writes
(also the one for a prefix declared for a DI type),
`show --conventions` and `new` on the command line (its own errors before
the `.bpmnlintrc`'s), and that a `.bpmnlintrc` without the rule, or
@@ -195,11 +197,11 @@ unchanged file shows none of them, the `E_VALIDATION` advice (the
catalogue fix, the `guide --short` line, the plugin skill's row) does not
send the reader to `bpmn validate`, and the guide's JSON error shape lists
the `errors` that advice points to),
-`test/domain/views/decision-link.test.ts` the decision link (`calledDecision`) in models
+`test/domain/editing/properties/decision-link.test.ts` the decision link (`calledDecision`) in models
without an engine (the unprefixed attribute; with `platform: 'none'` the
spelling of the task's link or of the file's other links kept), Camunda 7,
Operaton and Camunda 8 files.
-`test/domain/views/views.test.ts` checks the reading views (`show --around`, `show
+`test/domain/views/reading-views.test.ts` checks the reading views (`show --around`, `show
--context`, lanes and message-flow names in `show`, message flows and
annotations in `show `) on the synthetic `test/fixtures/views/claims.bpmn`
and holds a byte budget: on a generated 120-task model the neighbourhood
@@ -221,7 +223,7 @@ profile in the warnings delta; and the strip of a removed node that must not
pull an expanded sub-process over a shape of another row (found by the gate's
fuzz campaign on the integrated build).
The round 1 verifier's findings, each with a test that fails on round 1:
-`test/domain/document/id-quality.test.ts` (ids and the report: flows at unnamed
+`test/domain/document/ids/id-quality.test.ts` (ids and the report: flows at unnamed
gateways named by their speaking ids (no `_2`), unnamed elements in a row
(the nearest named anchor once; where the prefix says the kind, `Task_`,
`task_`, the anchor's kind where an id would repeat the anchor's,
@@ -240,11 +242,11 @@ delta: platform findings counted, a shrinking `W_DUPLICATE_NAME`, a bridge
that takes over a removed flow's id, warnings of a batch's final state, `new`
with the platform's findings),
`test/domain/validation/c8-feel-and-deploy.test.ts` (Camunda 8, see "Engine checks (Camunda 8)"),
-`test/domain/views/views-context.test.ts` (`show --context` on a synthetic shop
+`test/domain/views/element-context.test.ts` (`show --context` on a synthetic shop
model: every boundary event of the sub-processes around an element, the
message of a message element with the flows of that message drawn to its
pool, a message flow's ends, what uses a message / signal / error /
-escalation), `test/domain/diagram/format-frames.test.ts` (align / place with selector sets
+escalation), `test/domain/diagram/commands/format-frames.test.ts` (align / place with selector sets
and explicit ids never take a shape out of its sub-process, also on the two
scenarios the fuzzer hit), `test/domain/editing/move-lanes.test.ts` (a node moved into a
flow between two lanes gets add's lane when it has none, keeps its own
@@ -255,11 +257,11 @@ fixes together (the message of `refAs` and speaking ids in `--summary`,
created elements not also listed as changed, the subscription redirect in the
warnings delta, a plain remove of a merge and `move` into a cross-lane flow
renaming flows after their new ends).
-`test/domain/document/drawn-ids.test.ts` checks that a full redraw gives id-less elements
+`test/domain/document/ids/drawn-ids.test.ts` checks that a full redraw gives id-less elements
ids before drawing them, and that an op giving a sequence flow without id
other ends gives it one from them, and `test/e2e/node-files.test.ts` the encodings (a file
is read as it declares, a write is UTF-8 and says so).
-`test/domain/diagram/layout-batch-parity.test.ts` checks issue #18, stage A, on the
+`test/domain/diagram/layout/incremental/batch-parity.test.ts` checks issue #18, stage A, on the
synthetic hand-drawn model of the plugin eval `edit-hand-drawn-model`: the
agent's batches (decision + "no" to the existing node, decision + a new "no"
branch, `split`, a merge gateway; each with and without the timer reminder
@@ -287,7 +289,7 @@ grows at its bottom), and next to an annotation on a flow the move
reroutes (no `through`); `E_NO_ROOM` names the space-tool run with the
fewest layout problems, and after other ops of a batch only the batch op
(no command for the file).
-`test/domain/diagram/layout-room-making.test.ts` checks issue #18, stage B, on synthetic
+`test/domain/diagram/layout/incremental/room-making.test.ts` checks issue #18, stage B, on synthetic
hand-drawn models (`test/fixtures/layout-room/`), the eval's model and a
public scenario: a boundary path above a line from an earlier boundary event
opens the row below its host (the line and its end move down), a decision
@@ -325,7 +327,7 @@ not in an `applyToXml`-only bundle; and the command (`-o`, the temp default,
`-o -`, `--json`, `--plane`, the usage errors). The synthetic
`test/fixtures/render/gallery.bpmn` has every element kind the renderer
draws (built with the CLI, then a group and multiple events added by hand).
-`test/domain/views/render-agent.test.ts` checks the agent features (stage 2): every layout
+`test/domain/views/render/render-agent.test.ts` checks the agent features (stage 2): every layout
problem of every scenario and fixture located inside the bounding box of its
elements on its plane (`locateProblems`; `layoutProblems` unchanged);
`--problems` listing every metrics problem in its order with the same ids,
@@ -340,7 +342,7 @@ mutating commands (text, JSON, the region of a large drawing, a failed render
that leaves the change standing); and the fuzz walk's render oracle. The
synthetic `test/fixtures/render/long.bpmn` (28 nodes in a row, 3,940 px
wide, built with the CLI) is the tiles and focus model.
-`test/domain/views/render-fixes.test.ts` checks the fixes after the verification of
+`test/domain/views/render/render-fixes.test.ts` checks the fixes after the verification of
issue #12 on synthetic models: broken or far-flung coordinates (NaN,
1e308, Infinity, 1e9, a negative size, far label bounds) are left out with a
note and never give `width="NaN"`, an all-broken plane is `E_RENDER`;
@@ -388,8 +390,8 @@ npx vitest run test/e2e/isomorphic.test.ts
not compile. It prints the minified and gzipped sizes, split like a host's
bundler would split them: the initial chunks of the whole entry and of an
`applyToXml`-only entry, and the chunks loaded on demand (the Camunda 7
- rules, the Camunda 8 rules with the FEEL, timer and schema-text checks,
- bpmn-auto-layout). It fails when a rule module
+ rules, the Camunda 8 rules with the FEEL, timer and schema-text checks).
+ It fails when a rule module
(`dist/platform/profiles/`: the profiles, their base, utils and checks) is in the initial chunks of
either entry, naming it; when a rule module imports a module the entry
loads, naming both (Rollup / Vite would put that module into the host's
@@ -644,8 +646,8 @@ node tools/speaking-ids.mjs # tools/scenarios
node tools/speaking-ids.mjs --baseline /dist/index.js ~/corpora/hand
```
-The regression tests: `test/domain/document/speaking-ids.test.ts` and
-`test/domain/document/conventions-ids.test.ts` (ids in every file style, renamed flows and
+The regression tests: `test/domain/document/ids/speaking-ids.test.ts` and
+`test/domain/document/ids/id-style.test.ts` (ids in every file style, renamed flows and
their DI, also after an end got another id earlier in the batch; a flow
form that stays put over many edits, also in snake_case and Pascal_Snake
bodies, ties, flows under an outvoted prefix or scope),
diff --git a/docs/validators.md b/docs/validators.md
index 72a44efa..fca423f2 100644
--- a/docs/validators.md
+++ b/docs/validators.md
@@ -12,7 +12,7 @@ flow and is refused; the second one passes.
```ts
import {
applyToXml,
- CliError,
+ BpmnError,
newXml,
type NamedValidator,
} from '@miragon/bpmn-cli';
@@ -50,7 +50,7 @@ try {
};
await applyToXml(xml, [archive], { validators: [houseRules] });
} catch (err) {
- if (!(err instanceof CliError)) throw err;
+ if (!(err instanceof BpmnError)) throw err;
err.code;
// 'E_VALIDATION'
err.message;
diff --git a/package-lock.json b/package-lock.json
index 99c0456f..c9811b5e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,7 +11,6 @@
"dependencies": {
"@miragon/bpmnlint-plugin-rules": "0.14.0",
"@resvg/resvg-wasm": "2.6.2",
- "bpmn-auto-layout": "2.0.0-alpha.2",
"bpmn-moddle": "^10.2.0",
"bpmnlint": "11.15.0",
"commander": "^15.0.0",
@@ -21,6 +20,7 @@
"bpmn": "bin/bpmn.js"
},
"devDependencies": {
+ "@biomejs/biome": "2.5.15",
"@types/node": "^26.0.0",
"archunit": "2.5.4",
"camunda-bpmn-moddle": "8.0.2",
@@ -36,6 +36,181 @@
"node": ">=22.12.0"
}
},
+ "node_modules/@biomejs/biome": {
+ "version": "2.5.15",
+ "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.15.tgz",
+ "integrity": "sha512-WZTW4slm/pdkh92K6t/3aEN++44JD1PQ7squ7RCMLI1flHGl43DVOIGXvCjHiZgBMO1V4uYxoFNtqrIA4qGuIQ==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "bin": {
+ "biome": "bin/biome"
+ },
+ "engines": {
+ "node": ">=14.21.3"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/biome"
+ },
+ "optionalDependencies": {
+ "@biomejs/cli-darwin-arm64": "2.5.15",
+ "@biomejs/cli-darwin-x64": "2.5.15",
+ "@biomejs/cli-linux-arm64": "2.5.15",
+ "@biomejs/cli-linux-arm64-musl": "2.5.15",
+ "@biomejs/cli-linux-x64": "2.5.15",
+ "@biomejs/cli-linux-x64-musl": "2.5.15",
+ "@biomejs/cli-win32-arm64": "2.5.15",
+ "@biomejs/cli-win32-x64": "2.5.15"
+ }
+ },
+ "node_modules/@biomejs/cli-darwin-arm64": {
+ "version": "2.5.15",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.15.tgz",
+ "integrity": "sha512-BZVzFhJ/mUvTLMYbc9x6el0o2Uv5zP7bACYyhFb6fSc1giroXm7PL0a5KqMJ9F+/BnkYRjagD/ROSDVOZ6eapg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-darwin-x64": {
+ "version": "2.5.15",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.15.tgz",
+ "integrity": "sha512-V5Kw63V+fVGNFhFrizDxMbGXAzZCh8kYzJpy3GI6gTapWDg7bZK9oYC9CbVgQSEdTnupz0U5Efz8Ev+5vdBBXw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-linux-arm64": {
+ "version": "2.5.15",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.15.tgz",
+ "integrity": "sha512-XaG7P7eeSLYETD3K9grfB0mQmpLQZjygdbsxekWBJAg4am79fGAtRpfRyNnTvXB2fZFNnJZG3nXm7HBat0+VUw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-linux-arm64-musl": {
+ "version": "2.5.15",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.15.tgz",
+ "integrity": "sha512-tGzZUTcJCV7tj5Adh/Gn6nR4MycqA3iohq2LEcrarAgRBkU0h0OKd2UzCRkOsOYOuUGaUJb4YmqRqyy2feGGSw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-linux-x64": {
+ "version": "2.5.15",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.15.tgz",
+ "integrity": "sha512-xE4iEW/3LqlYj9GFgGrFsFSH51dEEpUbYWBfeOv0q87WkUDxK2o/HhreSb7qMJqck70RVM6Lg96hgedcAYcOkA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-linux-x64-musl": {
+ "version": "2.5.15",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.15.tgz",
+ "integrity": "sha512-IlxUcyxilVGPsE008x13pWdkTbU3nQpP2i9b5UrbOYj6goBkKsRX1XB7yJLcV+O1H9LBchIIm4adOaVLZBX0ZQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-win32-arm64": {
+ "version": "2.5.15",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.15.tgz",
+ "integrity": "sha512-2kPKzhNlm8C+Ru3GcO0Me2ODkCBLrVOUNuyi84RJyWDVKAA4+Kjj3jMjL938lF6BRfvltC4vB2nPY9SvN51Ovg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-win32-x64": {
+ "version": "2.5.15",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.15.tgz",
+ "integrity": "sha512-yAzh4UqEImV6Hcy0zjUqNfJAsUhoD64FBQCdTG8Md/Z2wQeVP/ZmJ3XRYAs1g8J9fjTFyl2fCtc5FB5til47+g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
"node_modules/@bpmn-io/feel-analyzer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@bpmn-io/feel-analyzer/-/feel-analyzer-0.3.0.tgz",
@@ -2180,25 +2355,6 @@
"node": "18 || 20 || >=22"
}
},
- "node_modules/bpmn-auto-layout": {
- "version": "2.0.0-alpha.2",
- "resolved": "https://registry.npmjs.org/bpmn-auto-layout/-/bpmn-auto-layout-2.0.0-alpha.2.tgz",
- "integrity": "sha512-Xg8BoRMijfECgWljIlE44hbGDAuF0BkjJuLfd5o3oOx1ileL06EJQhlgO040xFIegfjxq91dVFAoxCL17wo53A==",
- "license": "MIT",
- "workspaces": [
- "example"
- ],
- "dependencies": {
- "bpmn-moddle": "^10.0.0",
- "min-dash": "^5.0.0"
- },
- "bin": {
- "bpmn-auto-layout": "bin/bpmn-auto-layout.js"
- },
- "engines": {
- "node": ">= 18"
- }
- },
"node_modules/bpmn-moddle": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/bpmn-moddle/-/bpmn-moddle-10.3.1.tgz",
diff --git a/package.json b/package.json
index 977c6fca..314cc117 100644
--- a/package.json
+++ b/package.json
@@ -52,6 +52,7 @@
"test:watch": "vitest",
"typecheck": "tsc -p tsconfig.json --noEmit",
"knip": "knip",
+ "lint": "biome lint",
"layout:regress": "node tools/layout-regress.mjs",
"bench": "node tools/bench/run.mjs",
"bench:summary": "node tools/bench/summary.mjs",
@@ -61,19 +62,19 @@
"docs:dev": "npm --prefix docs run dev",
"docs:build": "npm --prefix docs run build",
"docs:preview": "npm --prefix docs run preview",
- "gate": "npm run build && knip && knip --production --exclude dependencies && jscpd && vitest run && node tools/iso/check.mjs && node tools/bench/regress-gate.mjs && node tools/fuzz/run.mjs --walks 12 --steps 15 --seed 1 --hard warn --out tools/fuzz/out/gate",
+ "gate": "npm run build && biome lint && knip && knip --production --exclude dependencies && jscpd && vitest run && node tools/iso/check.mjs && node tools/bench/regress-gate.mjs && node tools/fuzz/run.mjs --walks 12 --steps 15 --seed 1 --hard warn --out tools/fuzz/out/gate",
"prepublishOnly": "npm run build"
},
"dependencies": {
"@miragon/bpmnlint-plugin-rules": "0.14.0",
"@resvg/resvg-wasm": "2.6.2",
- "bpmn-auto-layout": "2.0.0-alpha.2",
"bpmn-moddle": "^10.2.0",
"bpmnlint": "11.15.0",
"commander": "^15.0.0",
"moddle": "^8.2.1"
},
"devDependencies": {
+ "@biomejs/biome": "2.5.15",
"@types/node": "^26.0.0",
"archunit": "2.5.4",
"camunda-bpmn-moddle": "8.0.2",
diff --git a/src/adapters/in/cli/AddElementCommand.ts b/src/adapters/in/cli/AddElementCommand.ts
index 1397e477..c1026097 100644
--- a/src/adapters/in/cli/AddElementCommand.ts
+++ b/src/adapters/in/cli/AddElementCommand.ts
@@ -49,7 +49,7 @@ export class AddElementCommand extends MutationCommand {
if (options['ifAbsent'] && !options['id']) {
throw usageError('--if-absent needs an explicit --id to check for', { hint: 'Pass --id _ (the id add would generate), or drop --if-absent.' });
}
- const op: AddElement = {
+ const command: AddElement = {
op: 'add',
kind,
...(name !== undefined ? { name } : {}),
@@ -60,6 +60,6 @@ export class AddElementCommand extends MutationCommand {
...(kv.length ? { set: parseKeyValues(kv) } : {}),
...this.triggerOptions(options),
};
- await this.mutate(file, [op], opts);
+ await this.mutate(file, [command], opts);
}
}
diff --git a/src/adapters/in/cli/AlignCommand.ts b/src/adapters/in/cli/AlignCommand.ts
index 48dbc1fc..c8e4f8cf 100644
--- a/src/adapters/in/cli/AlignCommand.ts
+++ b/src/adapters/in/cli/AlignCommand.ts
@@ -18,8 +18,8 @@ export class AlignCommand extends FormatCommand {
).action(async (file: string, ids: string[], options: RawOpts) => {
const sel = this.selectorsOf(ids, options, true);
if (!sel.path && !sel.kind && !sel.branch && ids.length < 2 && !options['to']) throw usageError('align needs two ids, or one id and --to ');
- const op: AlignShapes = { op: 'align', ...sel, axis: options['axis'] as AlignShapes['axis'], ...this.pick(options, ['to'] as const) };
- await this.runFormat(file, op, options);
+ const command: AlignShapes = { op: 'align', ...sel, axis: options['axis'] as AlignShapes['axis'], ...this.pick(options, ['to'] as const) };
+ await this.runFormat(file, command, options);
});
}
}
diff --git a/src/adapters/in/cli/ApplyOpsCommand.ts b/src/adapters/in/cli/ApplyOpsCommand.ts
index e8f6ab59..05f9c53f 100644
--- a/src/adapters/in/cli/ApplyOpsCommand.ts
+++ b/src/adapters/in/cli/ApplyOpsCommand.ts
@@ -1,9 +1,7 @@
import { readFile } from 'node:fs/promises';
import type { Command as CommandType } from 'commander';
-import { CommandParser } from '../../../domain/commands/CommandParser.js';
import type { EditCommand } from '../../../domain/commands/types.js';
import { ioError, usageError } from '../../../domain/document/utils/errors.js';
-import { parseOpsJson } from '../../../domain/commands/utils/ops-json.js';
import type { RawOpts } from './CliCommand.js';
import { MutationCommand } from './MutationCommand.js';
@@ -17,8 +15,8 @@ export class ApplyOpsCommand extends MutationCommand {
private async run(file: string, opsFile: string | undefined, options: RawOpts): Promise {
const opts = this.mutationOptions(options);
- const ops: EditCommand[] = new CommandParser().parse(parseOpsJson(await this.readOpsText(file, opsFile)));
- await this.mutate(file, ops, opts);
+ const commands: EditCommand[] = this.cli.useCases.runCommands.parseCommandsJson({ json: await this.readOpsText(file, opsFile) });
+ await this.mutate(file, commands, opts);
}
private async readOpsText(file: string, opsFile: string | undefined): Promise {
diff --git a/src/adapters/in/cli/Cli.ts b/src/adapters/in/cli/Cli.ts
index fd4b85c2..a7f406e3 100644
--- a/src/adapters/in/cli/Cli.ts
+++ b/src/adapters/in/cli/Cli.ts
@@ -1,7 +1,7 @@
import { Command, CommanderError, type Command as CommandType } from 'commander';
import { Doc } from '../../../domain/document/Doc.js';
-import { type Warning } from '../../../domain/document/CliError.js';
-import { ioError, isCliError, usageError } from '../../../domain/document/utils/errors.js';
+import type { Finding } from '../../../domain/document/Finding.js';
+import { ioError, isBpmnError, usageError } from '../../../domain/document/utils/errors.js';
import { validatorTag } from '../report/utils/findings.js';
import type { EditDocumentUseCase } from '../../../application/port/in/EditDocumentUseCase.js';
import type { CreateDocumentUseCase } from '../../../application/port/in/CreateDocumentUseCase.js';
@@ -11,6 +11,8 @@ import type { ReadDocumentUseCase } from '../../../application/port/in/ReadDocum
import type { ShowModelUseCase } from '../../../application/port/in/ShowModelUseCase.js';
import type { ShowConventionsUseCase } from '../../../application/port/in/ShowConventionsUseCase.js';
import type { FindElementsUseCase } from '../../../application/port/in/FindElementsUseCase.js';
+import type { AnalyzeLayoutUseCase } from '../../../application/port/in/AnalyzeLayoutUseCase.js';
+import type { RunCommandsUseCase } from '../../../application/port/in/RunCommandsUseCase.js';
import type { RenderDiagramUseCase } from '../../../application/port/in/RenderDiagramUseCase.js';
import { describeOptionsByDefinitions } from './utils/definition-help.js';
import { AddElementCommand } from './AddElementCommand.js';
@@ -60,6 +62,8 @@ export interface CliUseCases {
showModel: ShowModelUseCase;
showConventions: ShowConventionsUseCase;
findElements: FindElementsUseCase;
+ analyzeLayout: AnalyzeLayoutUseCase;
+ runCommands: RunCommandsUseCase;
renderDiagram: RenderDiagramUseCase;
}
@@ -184,7 +188,7 @@ export class Cli {
/** Writes the error to stderr and sets the exit code. */
printError(err: unknown, json: boolean | undefined): void {
const stderr = this.io.stderr;
- if (isCliError(err)) {
+ if (isBpmnError(err)) {
if (json) {
stderr.write(`${JSON.stringify({ ok: false, error: err.toJSON() })}\n`);
} else {
@@ -193,7 +197,7 @@ export class Cli {
if (details.element) lines.push(` element: ${String(details.element)}`);
if (Array.isArray(details.related) && details.related.length) lines.push(` related: ${details.related.join(', ')}`);
if (Array.isArray(details.candidates) && details.candidates.length) lines.push(` candidates: ${details.candidates.join(', ')}`);
- if (Array.isArray(details.errors)) for (const issue of details.errors as Warning[]) lines.push(` ${validatorTag(issue)}${issue.code}${issue.element ? ` ${issue.element}` : ''}: ${issue.message}${issue.hint ? ` (${issue.hint})` : ''}`);
+ if (Array.isArray(details.errors)) for (const issue of details.errors as Finding[]) lines.push(` ${validatorTag(issue)}${issue.code}${issue.element ? ` ${issue.element}` : ''}: ${issue.message}${issue.hint ? ` (${issue.hint})` : ''}`);
if (Array.isArray(details.warnings)) for (const warning of details.warnings as string[]) lines.push(` ${warning}`);
if (details.op !== undefined) lines.push(` op: #${String(details.op)}`);
if (details.hint) lines.push(` hint: ${String(details.hint)}`);
diff --git a/src/adapters/in/cli/ColorCommand.ts b/src/adapters/in/cli/ColorCommand.ts
index 766e59a3..469c9f79 100644
--- a/src/adapters/in/cli/ColorCommand.ts
+++ b/src/adapters/in/cli/ColorCommand.ts
@@ -11,8 +11,8 @@ export class ColorCommand extends FormatCommand {
this.withMutationOptions(
this.withSelectors(this.define(program, 'color').addOption(new Option('--color ').makeOptionMandatory())),
).action(async (file: string, ids: string[], options: RawOpts) => {
- const op: ColorElements = { op: 'color', ...this.selectorsOf(ids, options, true), color: options['color'] as ColorElements['color'] };
- await this.runFormat(file, op, options);
+ const command: ColorElements = { op: 'color', ...this.selectorsOf(ids, options, true), color: options['color'] as ColorElements['color'] };
+ await this.runFormat(file, command, options);
});
}
}
diff --git a/src/adapters/in/cli/CompactCommand.ts b/src/adapters/in/cli/CompactCommand.ts
index c7fee166..2e974898 100644
--- a/src/adapters/in/cli/CompactCommand.ts
+++ b/src/adapters/in/cli/CompactCommand.ts
@@ -10,8 +10,8 @@ import { FormatCommand } from './FormatCommand.js';
export class CompactCommand extends FormatCommand {
register(program: CommandType): void {
this.withMutationOptions(this.define(program, 'compact')).action((file: string, ids: string[], options: RawOpts) => {
- const op: CompactDiagram = { op: 'compact', ...(ids.length ? { ids } : {}) };
- return this.runFormat(file, op, options);
+ const command: CompactDiagram = { op: 'compact', ...(ids.length ? { ids } : {}) };
+ return this.runFormat(file, command, options);
});
}
}
diff --git a/src/adapters/in/cli/ConnectCommand.ts b/src/adapters/in/cli/ConnectCommand.ts
index 5fdabd8a..01cb20b2 100644
--- a/src/adapters/in/cli/ConnectCommand.ts
+++ b/src/adapters/in/cli/ConnectCommand.ts
@@ -24,7 +24,7 @@ export class ConnectCommand extends MutationCommand {
private async run(file: string, source: string, target: string, options: RawOpts): Promise {
const opts = this.mutationOptions(options);
this.checkFlowFlags(options, false);
- const op: ConnectElements = {
+ const command: ConnectElements = {
op: 'connect',
source,
target,
@@ -32,6 +32,6 @@ export class ConnectCommand extends MutationCommand {
...givenFlags(options, ['default', 'ifAbsent']),
...(options['condition'] !== undefined ? { condition: String(options['condition']) } : {}),
};
- await this.mutate(file, [op], opts);
+ await this.mutate(file, [command], opts);
}
}
diff --git a/src/adapters/in/cli/ExtensionElementsCommand.ts b/src/adapters/in/cli/ExtensionElementsCommand.ts
index 733753f5..054432cf 100644
--- a/src/adapters/in/cli/ExtensionElementsCommand.ts
+++ b/src/adapters/in/cli/ExtensionElementsCommand.ts
@@ -1,5 +1,4 @@
import type { Command as CommandType } from 'commander';
-import { ExtensionLister } from '../../../domain/document/ExtensionLister.js';
import type { EditExtensionElements } from '../../../domain/commands/types.js';
import type { RawOpts } from './CliCommand.js';
import { MutationCommand } from './MutationCommand.js';
@@ -29,7 +28,7 @@ export class ExtensionElementsCommand extends MutationCommand {
private async add(file: string, id: string, type: string, attrs: string[], options: RawOpts): Promise {
const opts = this.mutationOptions(options);
- const op: EditExtensionElements = {
+ const command: EditExtensionElements = {
op: 'ext',
id,
action: 'add',
@@ -38,23 +37,23 @@ export class ExtensionElementsCommand extends MutationCommand {
...givenStrings(options, ['body', 'xml']),
...givenFlags(options, ['replace']),
};
- await this.mutate(file, [op], opts);
+ await this.mutate(file, [command], opts);
}
/** `2` or `loop.2` is an index into the extension elements; anything else a selector. */
private async remove(file: string, id: string, typeOrIndex: string, options: RawOpts): Promise {
const opts = this.mutationOptions(options);
const indexed = /^(?:(definition|loop|condition)\.)?(\d+)$/.exec(typeOrIndex.trim());
- const op: EditExtensionElements = indexed
+ const command: EditExtensionElements = indexed
? { op: 'ext', id, action: 'remove', index: Number(indexed[2]), ...(indexed[1] ? { slot: indexed[1] as EditExtensionElements['slot'] } : {}) }
: { op: 'ext', id, action: 'remove', type: typeOrIndex };
- await this.mutate(file, [op], opts);
+ await this.mutate(file, [command], opts);
}
private async list(file: string, id: string, options: RawOpts): Promise {
const doc = await this.cli.readDocArg(file);
const el = doc.require(id);
- const items = new ExtensionLister().listAll(el);
+ const items = this.cli.useCases.showModel.allExtensions({ element: el });
if (options['json']) this.cli.printJson(items, !!options['pretty']);
else this.cli.print(renderExtensionList(items));
}
diff --git a/src/adapters/in/cli/FindCommand.ts b/src/adapters/in/cli/FindCommand.ts
index ce90100a..665980b0 100644
--- a/src/adapters/in/cli/FindCommand.ts
+++ b/src/adapters/in/cli/FindCommand.ts
@@ -1,5 +1,4 @@
import type { Command as CommandType } from 'commander';
-import { assertKindToken } from '../../../domain/document/utils/kinds.js';
import { CliCommand, type RawOpts } from './CliCommand.js';
import { renderFind } from '../report/utils/view-lines.js';
@@ -18,7 +17,7 @@ export class FindCommand extends CliCommand {
/** Kind tokens fail with the documented kind codes (like `add`), not commander's text. */
private kindArg(value: string): string {
- assertKindToken(value);
+ this.cli.useCases.findElements.assertKind({ kind: value });
return value;
}
}
diff --git a/src/adapters/in/cli/FormatCommand.ts b/src/adapters/in/cli/FormatCommand.ts
index 34353ecc..4104508a 100644
--- a/src/adapters/in/cli/FormatCommand.ts
+++ b/src/adapters/in/cli/FormatCommand.ts
@@ -38,8 +38,8 @@ export abstract class FormatCommand extends MutationCommand {
return out;
}
- protected async runFormat(file: string, op: EditCommand, options: RawOpts): Promise {
+ protected async runFormat(file: string, command: EditCommand, options: RawOpts): Promise {
const opts = this.mutationOptions(options);
- await this.mutate(file, [op], opts);
+ await this.mutate(file, [command], opts);
}
}
diff --git a/src/adapters/in/cli/InsertSpaceCommand.ts b/src/adapters/in/cli/InsertSpaceCommand.ts
index 16a2da97..ab180995 100644
--- a/src/adapters/in/cli/InsertSpaceCommand.ts
+++ b/src/adapters/in/cli/InsertSpaceCommand.ts
@@ -20,8 +20,8 @@ export class InsertSpaceCommand extends FormatCommand {
}),
).action(async (file: string, options: RawOpts) => {
this.checkFlagGroups(options, [['after', 'below']], 'space needs --after (horizontal space) or --below (vertical space)');
- const op: InsertSpace = { op: 'space', ...this.pick(options, ['after', 'below'] as const), ...(options['by'] !== undefined ? { by: options['by'] as InsertSpace['by'] } : {}) };
- await this.runFormat(file, op, options);
+ const command: InsertSpace = { op: 'space', ...this.pick(options, ['after', 'below'] as const), ...(options['by'] !== undefined ? { by: options['by'] as InsertSpace['by'] } : {}) };
+ await this.runFormat(file, command, options);
});
}
}
diff --git a/src/adapters/in/cli/LabelSideCommand.ts b/src/adapters/in/cli/LabelSideCommand.ts
index 32bea683..68715d04 100644
--- a/src/adapters/in/cli/LabelSideCommand.ts
+++ b/src/adapters/in/cli/LabelSideCommand.ts
@@ -10,8 +10,8 @@ export class LabelSideCommand extends FormatCommand {
register(program: CommandType): void {
this.withMutationOptions(this.define(program, 'label').addOption(new Option('--side ').makeOptionMandatory())).action(
(file: string, id: string, options: RawOpts) => {
- const op: PlaceLabel = { op: 'label', id, side: options['side'] as PlaceLabel['side'] };
- return this.runFormat(file, op, options);
+ const command: PlaceLabel = { op: 'label', id, side: options['side'] as PlaceLabel['side'] };
+ return this.runFormat(file, command, options);
},
);
}
diff --git a/src/adapters/in/cli/LayoutMetricsCommand.ts b/src/adapters/in/cli/LayoutMetricsCommand.ts
index 5efbe99a..bf89bd7e 100644
--- a/src/adapters/in/cli/LayoutMetricsCommand.ts
+++ b/src/adapters/in/cli/LayoutMetricsCommand.ts
@@ -1,5 +1,4 @@
import type { Command as CommandType } from 'commander';
-import { LayoutAnalysis } from '../../../domain/diagram/di/LayoutAnalysis.js';
import { metricsJson } from '../report/utils/metrics-json.js';
import { CliCommand, type RawOpts } from './CliCommand.js';
import { MetricsText } from '../report/MetricsText.js';
@@ -12,7 +11,7 @@ export class LayoutMetricsCommand extends CliCommand {
register(program: CommandType): void {
this.withJsonOptions(this.define(program, 'metrics')).action(async (file: string, options: RawOpts) => {
const doc = await this.cli.readDocArg(file);
- const metrics = LayoutAnalysis.of(doc.definitions);
+ const metrics = this.cli.useCases.analyzeLayout.measure({ definitions: doc.definitions });
if (options['json']) this.cli.printJson({ file, ...metricsJson(metrics) }, !!options['pretty']);
else this.cli.print(new MetricsText(metrics).text());
});
diff --git a/src/adapters/in/cli/MoveCommand.ts b/src/adapters/in/cli/MoveCommand.ts
index 436311ba..edad5ec6 100644
--- a/src/adapters/in/cli/MoveCommand.ts
+++ b/src/adapters/in/cli/MoveCommand.ts
@@ -24,15 +24,15 @@ export class MoveCommand extends MutationCommand {
private async run(file: string, ids: string[], options: RawOpts): Promise {
const opts = this.mutationOptions(options);
this.checkPlacementFlags(options);
- const op: MoveElements = {
+ const command: MoveElements = {
op: 'move',
ids,
...givenStrings(options, ['after', 'before', 'flow', 'in', 'on']),
...(options['lane'] !== undefined ? { lane: String(options['lane']) } : {}),
};
- if (!op.after && !op.before && !op.flow && !op.in && !op.on && op.lane === undefined) {
+ if (!command.after && !command.before && !command.flow && !command.in && !command.on && command.lane === undefined) {
throw usageError('move needs one of --after, --before, --flow, --in, --on or --lane');
}
- await this.mutate(file, [op], opts);
+ await this.mutate(file, [command], opts);
}
}
diff --git a/src/adapters/in/cli/MutationCommand.ts b/src/adapters/in/cli/MutationCommand.ts
index f3101a16..36e75d89 100644
--- a/src/adapters/in/cli/MutationCommand.ts
+++ b/src/adapters/in/cli/MutationCommand.ts
@@ -11,7 +11,7 @@ import { RENDER_MAX_SIZE, type RenderOptions } from '../../../domain/views/rende
import { changeHighlight } from '../report/utils/highlight.js';
import { type MutationReport, type MutationSummary } from '../report/command-report.js';
import { RenderReport } from './RenderReport.js';
-import { isCliError, usageError } from '../../../domain/document/utils/errors.js';
+import { isBpmnError, usageError } from '../../../domain/document/utils/errors.js';
import { CliCommand, type RawOpts } from './CliCommand.js';
import type { CliStream } from './Cli.js';
import { renderFormat, renderTarget } from './utils/flags.js';
@@ -63,11 +63,7 @@ export abstract class MutationCommand extends CliCommand {
.option(...commonOption('--show'))
.option(...commonOption('--strict'))
.option(...commonOption('--keep-flow-ids'))
- .option(...commonOption('--no-keep-flow-ids'))
- .option(...commonOption('--engine'), (raw: string) => {
- if (raw !== 'clean' && raw !== 'auto') throw new InvalidArgumentError('expected clean or auto');
- return raw;
- });
+ .option(...commonOption('--no-keep-flow-ids'));
}
protected withTriggerOptions(cmd: CommandType): CommandType {
@@ -97,7 +93,6 @@ export abstract class MutationCommand extends CliCommand {
show: !!options['show'],
strict: !!options['strict'],
...(options['render'] !== undefined ? { render: String(options['render']) } : {}),
- ...(options['engine'] ? { engine: options['engine'] as 'clean' | 'auto' } : {}),
...(keepFlowIds !== undefined ? { keepFlowIds } : {}),
...(keepFlowIdsEnv !== undefined ? { keepFlowIdsEnv } : {}),
...this.lintOptions(options),
@@ -125,18 +120,18 @@ export abstract class MutationCommand extends CliCommand {
}
/** Applies `ops` to `file` (`-`: stdin). */
- protected async mutate(file: string, ops: EditCommand[], opts: MutationFlags): Promise {
+ protected async mutate(file: string, commands: EditCommand[], opts: MutationFlags): Promise {
try {
await this.runMutation(
file,
opts,
- (fileOptions) => this.cli.useCases.editDocument.edit({ ...fileOptions, source: { file }, commands: ops }),
+ (fileOptions) => this.cli.useCases.editDocument.edit({ ...fileOptions, source: { file }, commands }),
(doc, fileOptions) => {
- return this.cli.useCases.editDocument.edit({ ...fileOptions, source: { doc }, commands: ops });
+ return this.cli.useCases.editDocument.edit({ ...fileOptions, source: { doc }, commands });
},
);
} catch (err) {
- throw this.withFile(this.keptIdsHint(err, ops, opts.keepFlowIdsEnv), file);
+ throw this.withFile(this.keptIdsHint(err, commands, opts.keepFlowIdsEnv), file);
}
}
@@ -198,7 +193,7 @@ export abstract class MutationCommand extends CliCommand {
const shot = await this.cli.useCases.renderDiagram.render({ source: { xml }, opts, format: renderFormat(undefined, target), out: target, stem: 'result', stdout: this.cli.io.stdout });
return { shot, lines: new RenderReport(shot).resultLines(highlight), json: { ...shot } };
} catch (err) {
- if (!isCliError(err)) throw err;
+ if (!isBpmnError(err)) throw err;
return { lines: [RenderReport.failureLine(err)], json: { error: err.toJSON() } };
}
}
@@ -234,8 +229,8 @@ export abstract class MutationCommand extends CliCommand {
* With kept flow ids, a missing id may be the one the default gives a flow an earlier op
* rewired; the hint says so, as whoever reads the error may not see the variable.
*/
- private keptIdsHint(err: unknown, ops: readonly EditCommand[], value: string | undefined): unknown {
- if (value === undefined || ops.length < 2 || !isCliError(err) || err.code !== 'E_NOT_FOUND') return err;
+ private keptIdsHint(err: unknown, commands: readonly EditCommand[], value: string | undefined): unknown {
+ if (value === undefined || commands.length < 2 || !isBpmnError(err) || err.code !== 'E_NOT_FOUND') return err;
const kept = `If it is the id a flow an earlier op gave other ends would be renamed to: flow ids are kept here (BPMN_KEEP_FLOW_IDS=${value}), so that flow kept its old id; use the old id, or --no-keep-flow-ids to rename it after its new ends.`;
const hint = err.details.hint;
err.details.hint = typeof hint === 'string' && hint ? `${hint} ${kept}` : kept;
@@ -244,7 +239,7 @@ export abstract class MutationCommand extends CliCommand {
/** Puts the file name into a refusal's runnable command (E_NO_ROOM) and its hint. */
private withFile(err: unknown, file: string): unknown {
- if (!isCliError(err) || typeof err.details['command'] !== 'string' || file === '-') return err;
+ if (!isBpmnError(err) || typeof err.details['command'] !== 'string' || file === '-') return err;
const quoted = /^[\w./@%+=:,-]+$/.test(file) ? file : `'${file.replace(/'/g, `'\\''`)}'`;
err.details['command'] = String(err.details['command']).replaceAll('', quoted);
if (typeof err.details.hint === 'string') err.details.hint = err.details.hint.replaceAll('bpmn space ', `bpmn space ${quoted}`);
diff --git a/src/adapters/in/cli/PlaceCommand.ts b/src/adapters/in/cli/PlaceCommand.ts
index 701e0165..cdd3b892 100644
--- a/src/adapters/in/cli/PlaceCommand.ts
+++ b/src/adapters/in/cli/PlaceCommand.ts
@@ -29,8 +29,8 @@ export class PlaceCommand extends FormatCommand {
return;
}
this.checkFlagGroups(options, [['rowOf', 'below', 'above'], ['columnOf', 'after', 'before']], 'place needs a row (--row-of, --below, --above) and/or a column (--column-of, --after, --before), or --side for a boundary event');
- const op: PlaceShapes = { op: 'place', ...this.selectorsOf(ids, options, true), ...this.pick(options, ['rowOf', 'below', 'above', 'columnOf', 'after', 'before'] as const) };
- await this.runFormat(file, op, options);
+ const command: PlaceShapes = { op: 'place', ...this.selectorsOf(ids, options, true), ...this.pick(options, ['rowOf', 'below', 'above', 'columnOf', 'after', 'before'] as const) };
+ await this.runFormat(file, command, options);
});
}
}
diff --git a/src/adapters/in/cli/RemoveElementsCommand.ts b/src/adapters/in/cli/RemoveElementsCommand.ts
index 116fb653..a450e2eb 100644
--- a/src/adapters/in/cli/RemoveElementsCommand.ts
+++ b/src/adapters/in/cli/RemoveElementsCommand.ts
@@ -20,12 +20,12 @@ export class RemoveElementsCommand extends MutationCommand {
private async run(file: string, ids: string[], options: RawOpts): Promise {
const opts = this.mutationOptions(options);
- const op: RemoveElements = {
+ const command: RemoveElements = {
op: 'remove',
ids,
bridge: options['bridge'] !== false,
...givenFlags(options, ['bridgeAll', 'withBranch', 'ifExists']),
};
- await this.mutate(file, [op], opts);
+ await this.mutate(file, [command], opts);
}
}
diff --git a/src/adapters/in/cli/RenderReport.ts b/src/adapters/in/cli/RenderReport.ts
index 423f0fb0..ea7a290d 100644
--- a/src/adapters/in/cli/RenderReport.ts
+++ b/src/adapters/in/cli/RenderReport.ts
@@ -1,8 +1,8 @@
import type { Shot } from '../../../application/port/out/WriteImagePort.js';
-import { AROUND_DEFAULT_DEPTH } from '../../../domain/views/context.js';
+import { AROUND_DEFAULT_DEPTH } from '../../../domain/views/around-view.js';
import type { RenderHighlight, RenderOptions } from '../../../domain/views/render/types.js';
import { scaleText } from '../../../domain/views/utils/scale.js';
-import type { CliError } from '../../../domain/document/CliError.js';
+import type { BpmnError } from '../../../domain/document/BpmnError.js';
export class RenderReport {
constructor(private readonly shot: Shot) {}
@@ -18,7 +18,7 @@ export class RenderReport {
return [`render: ${lines[0]!}`, ...lines.slice(1).map((line) => ` ${line}`)];
}
- static failureLine(err: CliError): string {
+ static failureLine(err: BpmnError): string {
return `render: failed ${err.code}: ${err.message}${err.details.hint ? ` (${String(err.details.hint)})` : ''}`;
}
diff --git a/src/adapters/in/cli/ReorderCommand.ts b/src/adapters/in/cli/ReorderCommand.ts
index 67e1b167..da8c695f 100644
--- a/src/adapters/in/cli/ReorderCommand.ts
+++ b/src/adapters/in/cli/ReorderCommand.ts
@@ -14,7 +14,7 @@ export class ReorderCommand extends MutationCommand {
private async run(file: string, nodeId: string, flowIds: string[], options: RawOpts): Promise {
const opts = this.mutationOptions(options);
- const op: ReorderElements = { op: 'order', id: nodeId, flows: flowIds };
- await this.mutate(file, [op], opts);
+ const command: ReorderElements = { op: 'order', id: nodeId, flows: flowIds };
+ await this.mutate(file, [command], opts);
}
}
diff --git a/src/adapters/in/cli/RetypeCommand.ts b/src/adapters/in/cli/RetypeCommand.ts
index 8d927e07..c39c8363 100644
--- a/src/adapters/in/cli/RetypeCommand.ts
+++ b/src/adapters/in/cli/RetypeCommand.ts
@@ -13,7 +13,7 @@ export class RetypeCommand extends MutationCommand {
private async run(file: string, id: string, kind: string, options: RawOpts): Promise {
const opts = this.mutationOptions(options);
- const op: ChangeElementType = { op: 'retype', id, kind, ...this.triggerOptions(options) };
- await this.mutate(file, [op], opts);
+ const command: ChangeElementType = { op: 'retype', id, kind, ...this.triggerOptions(options) };
+ await this.mutate(file, [command], opts);
}
}
diff --git a/src/adapters/in/cli/RouteCommand.ts b/src/adapters/in/cli/RouteCommand.ts
index 6d6cb39f..51f1aa1f 100644
--- a/src/adapters/in/cli/RouteCommand.ts
+++ b/src/adapters/in/cli/RouteCommand.ts
@@ -13,8 +13,8 @@ export class RouteCommand extends FormatCommand {
.option('--exit ')
.option('--entry '),
).action((file: string, flowId: string, options: RawOpts) => {
- const op: RerouteFlow = { op: 'route', id: flowId, ...(this.pick(options, ['exit', 'entry'] as const) as Pick) };
- return this.runFormat(file, op, options);
+ const command: RerouteFlow = { op: 'route', id: flowId, ...(this.pick(options, ['exit', 'entry'] as const) as Pick) };
+ return this.runFormat(file, command, options);
});
}
}
diff --git a/src/adapters/in/cli/SetPropertiesCommand.ts b/src/adapters/in/cli/SetPropertiesCommand.ts
index 233ac336..08edfa72 100644
--- a/src/adapters/in/cli/SetPropertiesCommand.ts
+++ b/src/adapters/in/cli/SetPropertiesCommand.ts
@@ -1,6 +1,6 @@
import type { Command as CommandType } from 'commander';
import type { SetProperties } from '../../../domain/commands/types.js';
-import { CliError } from '../../../domain/document/CliError.js';
+import { BpmnError } from '../../../domain/document/BpmnError.js';
import type { RawOpts } from './CliCommand.js';
import { MutationCommand } from './MutationCommand.js';
import { parseKeyValues } from './utils/flags.js';
@@ -18,8 +18,8 @@ export class SetPropertiesCommand extends MutationCommand {
private async run(file: string, id: string, keyValues: string[], options: RawOpts): Promise {
const opts = this.mutationOptions(options);
const unset = (options['unset'] as string[]) ?? [];
- if (!keyValues.length && !unset.length) throw new CliError('E_USAGE', 'Nothing to set: pass key=value pairs or --unset ', 'usage');
- const op: SetProperties = { op: 'set', id, values: parseKeyValues(keyValues), ...(unset.length ? { unset } : {}) };
- await this.mutate(file, [op], opts);
+ if (!keyValues.length && !unset.length) throw new BpmnError('E_USAGE', 'Nothing to set: pass key=value pairs or --unset ', 'usage');
+ const command: SetProperties = { op: 'set', id, values: parseKeyValues(keyValues), ...(unset.length ? { unset } : {}) };
+ await this.mutate(file, [command], opts);
}
}
diff --git a/src/adapters/in/cli/ShowCommand.ts b/src/adapters/in/cli/ShowCommand.ts
index 1262f2f8..415298c9 100644
--- a/src/adapters/in/cli/ShowCommand.ts
+++ b/src/adapters/in/cli/ShowCommand.ts
@@ -1,7 +1,7 @@
import { existsSync } from 'node:fs';
import type { Command as CommandType } from 'commander';
import { ShownText } from '../report/ShownText.js';
-import { renderConventions } from '../../../domain/document/utils/conventions-text.js';
+import { renderConventions } from '../report/utils/conventions-text.js';
import type { ViewOptions } from '../../../domain/views/ViewRequest.js';
import { usageError } from '../../../domain/document/utils/errors.js';
import { CliCommand, type RawOpts } from './CliCommand.js';
diff --git a/src/adapters/in/cli/TidyCommand.ts b/src/adapters/in/cli/TidyCommand.ts
index 048e1879..7e85b9d7 100644
--- a/src/adapters/in/cli/TidyCommand.ts
+++ b/src/adapters/in/cli/TidyCommand.ts
@@ -9,8 +9,8 @@ import { FormatCommand } from './FormatCommand.js';
export class TidyCommand extends FormatCommand {
register(program: CommandType): void {
this.withMutationOptions(this.withSelectors(this.define(program, 'tidy'))).action(async (file: string, ids: string[], options: RawOpts) => {
- const op: TidyShapes = { op: 'tidy', ...this.selectorsOf(ids, options, false) };
- await this.runFormat(file, op, options);
+ const command: TidyShapes = { op: 'tidy', ...this.selectorsOf(ids, options, false) };
+ await this.runFormat(file, command, options);
});
}
}
diff --git a/src/adapters/in/cli/utils/flags.ts b/src/adapters/in/cli/utils/flags.ts
index 1b73cdbc..8529a065 100644
--- a/src/adapters/in/cli/utils/flags.ts
+++ b/src/adapters/in/cli/utils/flags.ts
@@ -1,14 +1,14 @@
import { extname } from 'node:path';
import { InvalidArgumentError } from 'commander';
import type { ImageFormat } from '../../../../application/port/out/WriteImagePort.js';
-import { CliError } from '../../../../domain/document/CliError.js';
+import { BpmnError } from '../../../../domain/document/BpmnError.js';
import { usageError } from '../../../../domain/document/utils/errors.js';
export function parseKeyValues(items: string[], what = 'property'): Record {
const out: Record = {};
for (const item of items) {
const idx = item.indexOf('=');
- if (idx <= 0) throw new CliError('E_USAGE', `Expected ${what} as key=value, got "${item}"`, 'usage');
+ if (idx <= 0) throw new BpmnError('E_USAGE', `Expected ${what} as key=value, got "${item}"`, 'usage');
out[item.slice(0, idx).trim()] = item.slice(idx + 1);
}
return out;
diff --git a/src/adapters/in/report/AgentGuide.ts b/src/adapters/in/report/AgentGuide.ts
index a73e3e1f..43e19193 100644
--- a/src/adapters/in/report/AgentGuide.ts
+++ b/src/adapters/in/report/AgentGuide.ts
@@ -1,5 +1,5 @@
import { EditCommandDefinitions } from '../../../domain/commands/definitions/EditCommandDefinitions.js';
-import { FORMAT_OP_NAMES, OP_NAMES } from '../../../domain/commands/utils/op-names.js';
+import { DIAGRAM_COMMAND_NAMES, COMMAND_NAMES } from '../../../domain/commands/utils/command-names.js';
import { usageError } from '../../../domain/document/utils/errors.js';
import { COMMANDS, COMMON_OPTIONS } from './CommandCatalogue.js';
import { COMMON_ERRORS, ERROR_CATALOGUE, TOP_ERRORS, TRIGGER_OPTIONS } from './guide.js';
@@ -191,7 +191,7 @@ export const GUIDE_TOPICS: Record`. */
private opKeyLines(): string[] {
const triggerKeys = new Set([...TRIGGER_OPTIONS.flatMap((trigger) => trigger.keys), 'nonInterrupting']);
- return OP_NAMES.map((opName) => {
- const fields = Object.entries(EditCommandDefinitions.of(opName).fields).filter(([key]) => !FULL_GUIDE_ONLY_KEYS.has(key));
+ return COMMAND_NAMES.map((commandName) => {
+ const fields = Object.entries(EditCommandDefinitions.of(commandName).fields).filter(([key]) => !FULL_GUIDE_ONLY_KEYS.has(key));
const folds = fields.filter(([key]) => triggerKeys.has(key)).length > 3;
const keys = fields.filter(([key]) => !folds || !triggerKeys.has(key)).map(([key, spec]) => (spec.required ? `${key}*` : key));
if (folds) keys.push('');
- return ` ${opName.padEnd(8)} ${keys.join(' ')}`;
+ return ` ${commandName.padEnd(8)} ${keys.join(' ')}`;
});
}
}
diff --git a/src/adapters/in/report/AroundText.ts b/src/adapters/in/report/AroundText.ts
index cc0b55c0..53bbe36f 100644
--- a/src/adapters/in/report/AroundText.ts
+++ b/src/adapters/in/report/AroundText.ts
@@ -1,6 +1,5 @@
-import type { AroundNode, AroundScope, AroundView } from '../../../domain/views/context.js';
-import { truncate } from '../../../domain/views/utils/view.js';
-import { annotationLine, DOC_MAX, flowText, implementationFlags, INDENT, messageFlowLine, named, quote, scalar } from './utils/view-lines.js';
+import type { AroundNode, AroundScope, AroundView } from '../../../domain/views/around-view.js';
+import { annotationLine, elementFlags, elementLine, flowText, flowTraits, implementationFlags, INDENT, messageFlowLine, named } from './utils/view-lines.js';
/**
* `bpmn show --around `: a header with what is shown and left out, then the window's
@@ -28,12 +27,8 @@ export class AroundText {
for (const node of view.nodes) {
this.heading(node.scope);
const hostShown = !!node.host && shownIds.has(node.host);
- const parts = [node.kind, node.id];
- if (node.name) parts.push(quote(node.name));
- const flags = AroundText.flags(node, hostShown);
- if (flags.length) parts.push(`[${flags.join(', ')}]`);
- let line = parts.join(' ');
- if (node.from?.length) line += ` <- ${node.from.map((flow) => `${flow.source} (${flow.id}${flow.name ? ` ${quote(flow.name)}` : ''})`).join(', ')}`;
+ let line = elementLine(node, AroundText.flags(node, hostShown));
+ if (node.from?.length) line += ` <- ${node.from.map((flow) => `${flow.source} (${[flow.id, ...flowTraits(flow)].join(' ')})`).join(', ')}`;
if (node.outgoing.length) line += ` -> ${node.outgoing.map(flowText).join(', ')}`;
out.push(INDENT.repeat(this.level(node.scope) + 1 + (hostShown ? 1 : 0)) + line);
}
@@ -69,16 +64,10 @@ export class AroundText {
}
private static flags(node: AroundNode, hostShown: boolean): string[] {
- const flags: string[] = [];
- if (node.host && !hostShown) flags.push(`on ${node.host}`);
- if (node.lane) flags.push(`lane=${node.lane}`);
- if (node.trigger) flags.push(node.trigger);
- if (node.nonInterrupting) flags.push('non-interrupting');
- if (node.expanded !== undefined) flags.push(node.expanded ? 'expanded' : 'collapsed');
- if (node.content) flags.push(`content: ${node.content} node${node.content === 1 ? '' : 's'}`);
- for (const [key, value] of Object.entries(node.props ?? {})) flags.push(`${key}=${scalar(value)}`);
- flags.push(...implementationFlags(node.impl));
- if (node.documentation) flags.push(`doc: ${quote(truncate(node.documentation, DOC_MAX))}`);
- return flags;
+ return elementFlags(node, {
+ leading: node.host && !hostShown ? [`on ${node.host}`] : [],
+ afterState: node.content ? [`content: ${node.content} node${node.content === 1 ? '' : 's'}`] : [],
+ vendor: implementationFlags(node.impl),
+ });
}
}
diff --git a/src/adapters/in/report/CommandCatalogue.ts b/src/adapters/in/report/CommandCatalogue.ts
index f4ce4dbc..f7ccb44a 100644
--- a/src/adapters/in/report/CommandCatalogue.ts
+++ b/src/adapters/in/report/CommandCatalogue.ts
@@ -73,7 +73,6 @@ export const COMMON_OPTIONS: CommonOptionDoc[] = [
{ option: '--strict', description: 'exit 5 when the result has warnings', help: 'exit with code 5 when there are warnings' },
{ option: '--keep-flow-ids', description: 'keep the id of every existing sequence flow the change gives other ends, where flow ids are API (generated code, process tests); W_FLOW_ID_STALE names each flow whose id no longer names its ends, with the `bpmn set id=` that renames it. Default: a flow whose id names its old ends is renamed after its new ones (`renamed:`). BPMN_KEEP_FLOW_IDS=1 (true, yes, on) makes keeping the default, 0 (false, no, off) or unset renames, any other value is E_USAGE', help: 'keep the id of every existing sequence flow the change gives other ends, instead of renaming a flow whose id names its old ends after its new ones; W_FLOW_ID_STALE names each flow whose id no longer names its ends. BPMN_KEEP_FLOW_IDS=1 makes keeping the default' },
{ option: '--no-keep-flow-ids', description: 'rename such flows after their new ends, also when BPMN_KEEP_FLOW_IDS=1' },
- { option: '--engine ', description: 'layout engine: clean (built-in, default: straight happy path, branches as bands below, loops underneath) or auto (bpmn-auto-layout)', flags: '--engine ', help: 'layout engine: clean (default) or auto (bpmn-auto-layout)' },
];
export function commandDoc(name: string): CommandDoc {
diff --git a/src/adapters/in/report/ContextText.ts b/src/adapters/in/report/ContextText.ts
index 8c9305d5..402c49d6 100644
--- a/src/adapters/in/report/ContextText.ts
+++ b/src/adapters/in/report/ContextText.ts
@@ -1,6 +1,6 @@
-import type { ContextCatch, ContextLink, ContextMessage, ContextRef, ElementContext } from '../../../domain/views/context.js';
+import type { ContextCatch, ContextLink, ContextMessage, ContextRef, ElementContext } from '../../../domain/views/around-view.js';
import { truncate } from '../../../domain/views/utils/view.js';
-import { detailMessageFlowText, DOC_MAX, flagValue, implementationFlags, named, quote, scalar } from './utils/view-lines.js';
+import { detailMessageFlowText, elementFlags, elementLine, flagValue, flowTraits, implementationFlags, named, quote } from './utils/view-lines.js';
/**
* `bpmn show --context`: the element's line, then one line per context
@@ -12,12 +12,8 @@ export class ContextText {
text(): string {
const context = this.context;
const out: string[] = [];
- const flags: string[] = [];
- if (context.trigger) flags.push(context.trigger);
- if (context.nonInterrupting) flags.push('non-interrupting');
- for (const [key, value] of Object.entries(context.props ?? {})) flags.push(`${key}=${scalar(value)}`);
- if (context.documentation) flags.push(`doc: ${quote(truncate(context.documentation, DOC_MAX))}`);
- out.push(`${context.kind} ${named(context.id, context.name)}${flags.length ? ` [${flags.join(', ')}]` : ''}`);
+ const { trigger, nonInterrupting, props, documentation } = context;
+ out.push(elementLine(context, elementFlags({ trigger, nonInterrupting, props, documentation })));
const impl = implementationFlags(context.implementation);
if (impl.length) out.push(`implementation: ${impl.join(', ')}`);
if (context.message) out.push(`message: ${ContextText.messageText(context.message)}`);
@@ -50,10 +46,7 @@ export class ContextText {
}
private static linkText(link: ContextLink): string {
- const flow = [link.flow];
- if (link.flowName) flow.push(quote(link.flowName));
- if (link.condition) flow.push(`if ${truncate(link.condition, 120)}`);
- if (link.default) flow.push('default');
+ const flow = [link.flow, ...flowTraits({ name: link.flowName, condition: link.condition, default: link.default })];
return `${ContextText.refText(link)} (${flow.join(' ')})${link.pool ? ` in ${named(link.pool, link.poolName)}` : ''}`;
}
diff --git a/src/adapters/in/report/DetailText.ts b/src/adapters/in/report/DetailText.ts
index 6cc2ae64..8d36a3d4 100644
--- a/src/adapters/in/report/DetailText.ts
+++ b/src/adapters/in/report/DetailText.ts
@@ -1,6 +1,6 @@
-import type { ElementDetail, ViewFlow } from '../../../domain/views/model-view.js';
+import type { ElementDetail, FlowRef } from '../../../domain/views/model-view.js';
import { truncate } from '../../../domain/views/utils/view.js';
-import { detailMessageFlowText, extensionLines, INDENT, named, quote, scalar } from './utils/view-lines.js';
+import { detailMessageFlowText, extensionLines, flowTraits, INDENT, named, quote, scalar } from './utils/view-lines.js';
/** `bpmn show ` as `key: value` lines (property keys are `set` keys). */
export class DetailText {
@@ -36,8 +36,8 @@ export class DetailText {
out.push(`${key}: ${scalar(value)}`);
printed.add(key);
}
- if (detail.incoming.length) out.push(`incoming: ${detail.incoming.map(DetailText.incomingText).join('; ')}`);
- if (detail.outgoing.length) out.push(`outgoing: ${detail.outgoing.map(DetailText.outgoingText).join('; ')}`);
+ if (detail.incoming.length) out.push(`incoming: ${detail.incoming.map((flow) => DetailText.flowEndText(flow, 'from', flow.source)).join('; ')}`);
+ if (detail.outgoing.length) out.push(`outgoing: ${detail.outgoing.map((flow) => DetailText.flowEndText(flow, 'to', flow.target)).join('; ')}`);
if (detail.boundary?.length) out.push(`boundary: ${detail.boundary.join(', ')}`);
if (detail.children?.length) out.push(`children: ${detail.children.join(', ')}`);
if (detail.messageFlows?.length) out.push(`message flows: ${detail.messageFlows.map(detailMessageFlowText).join('; ')}`);
@@ -68,21 +68,7 @@ export class DetailText {
return out.join('\n');
}
- private static incomingText(flow: ElementDetail['incoming'][number] & { conditionResource?: string }): string {
- const parts = [flow.id, 'from', flow.source];
- if (flow.name) parts.push(quote(flow.name));
- if (flow.condition) parts.push(`if ${flow.condition}`);
- else if (flow.conditionResource) parts.push(`if resource ${flow.conditionResource}`);
- if (flow.default) parts.push('default');
- return parts.join(' ');
- }
-
- private static outgoingText(flow: ViewFlow): string {
- const parts = [flow.id, 'to', flow.target];
- if (flow.name) parts.push(quote(flow.name));
- if (flow.condition) parts.push(`if ${flow.condition}`);
- else if (flow.conditionResource) parts.push(`if resource ${flow.conditionResource}`);
- if (flow.default) parts.push('default');
- return parts.join(' ');
+ private static flowEndText(flow: FlowRef, side: 'from' | 'to', end: string): string {
+ return [flow.id, side, end, ...flowTraits(flow, true)].join(' ');
}
}
diff --git a/src/adapters/in/report/KindsReference.ts b/src/adapters/in/report/KindsReference.ts
index 7553b0f7..363466b8 100644
--- a/src/adapters/in/report/KindsReference.ts
+++ b/src/adapters/in/report/KindsReference.ts
@@ -1,20 +1,20 @@
import { LAYOUT_MODES } from '../../../domain/diagram/types.js';
import { Doc } from '../../../domain/document/Doc.js';
-import { EditContext } from '../../../domain/document/EditContext.js';
-import { KINDS, TRIGGER_TYPES, type Family, type KindDef, type Trigger } from '../../../domain/document/utils/kinds.js';
+import { EditSession } from '../../../domain/document/EditSession.js';
+import { KINDS, TRIGGERS, type Family, type KindDef, type Trigger } from '../../../domain/document/Kinds.js';
import { SWATCHES } from '../../../domain/diagram/utils/colors.js';
-import { OPS_SCHEMA } from '../../../domain/commands/OpsSchemaBuilder.js';
+import { OPS_SCHEMA } from './OpsSchemaBuilder.js';
import { opsExample } from '../../../domain/commands/utils/op-fields.js';
import { EditCommandDefinitions } from '../../../domain/commands/definitions/EditCommandDefinitions.js';
import { EXTENSION_STRUCTURE } from '../../../domain/editing/extensions/ExtensionStructure.js';
import { NESTED_SLOTS, NESTED_TYPES, type NestedSlot } from '../../../domain/document/utils/nested-slots.js';
-import { SET_KEYS, SetKeyCatalog, type SetKeyDoc } from '../../../domain/editing/SetKeyCatalog.js';
-import { OP_NAMES } from '../../../domain/commands/utils/op-names.js';
+import { SET_KEYS, SetKeyCatalog, type SetKeyDoc } from '../../../domain/editing/properties/SetKeyCatalog.js';
+import { COMMAND_NAMES } from '../../../domain/commands/utils/command-names.js';
import { ZEEBE } from '../../../domain/document/vendor/ZeebeDescriptor.js';
import { usageError } from '../../../domain/document/utils/errors.js';
import { ERROR_CATALOGUE, EXIT_CODE_DOCS, ID_CONVENTIONS, NESTED_SELECTORS, NON_INTERRUPTING_DOC, PLACEMENT_DOCS, PLACEMENT_NOTES, TRIGGER_OPTIONS } from './guide.js';
import { heading, table } from './utils/table.js';
-import { NestedSlots } from '../../../domain/editing/NestedSlots.js';
+import { NestedSlots } from '../../../domain/editing/properties/NestedSlots.js';
const FAMILY_LABELS: Record = {
task: 'Tasks',
@@ -80,7 +80,7 @@ export class KindsReference {
TRIGGER_OPTIONS.map((trigger) => [
trigger.trigger,
{
- ...(trigger.trigger !== 'none' ? { type: TRIGGER_TYPES[trigger.trigger] } : {}),
+ ...(trigger.trigger !== 'none' ? { type: TRIGGERS[trigger.trigger].definition } : {}),
option: trigger.option,
keys: trigger.keys,
description: trigger.description,
@@ -145,10 +145,10 @@ export class KindsReference {
opsSection(): string {
const out: string[] = [heading('OPS JSON (bpmn apply ops.json; keys = CLI flags in lowerCamelCase; full schema: bpmn kinds --json -> ops)')];
- for (const opName of OP_NAMES) {
- const required = Object.entries(EditCommandDefinitions.of(opName).fields).filter(([, spec]) => spec.required).map(([key]) => key);
- out.push(` ${opName.padEnd(8)} ${EditCommandDefinitions.of(opName).description}`);
- out.push(` keys: ${Object.keys(EditCommandDefinitions.of(opName).fields).join(', ')}${required.length ? ` (required: ${required.join(', ')})` : ''}`);
+ for (const commandName of COMMAND_NAMES) {
+ const required = Object.entries(EditCommandDefinitions.of(commandName).fields).filter(([, spec]) => spec.required).map(([key]) => key);
+ out.push(` ${commandName.padEnd(8)} ${EditCommandDefinitions.of(commandName).description}`);
+ out.push(` keys: ${Object.keys(EditCommandDefinitions.of(commandName).fields).join(', ')}${required.length ? ` (required: ${required.join(', ')})` : ''}`);
}
out.push('\nBatch aliases: "as": "$name" on add / connect / split / split nodes ("flowAs" on add and split nodes: the flow into the');
out.push('node, on a prepend before a join or a node without incoming flow the flow out of it; "outAs" on add: the one flow');
@@ -259,11 +259,11 @@ export class KindsReference {
private nestedKeyTable(): Record> {
const doc = Doc.create();
- const edit = new EditContext(doc);
+ const edit = new EditSession(doc);
const table = {} as Record>;
for (const slot of NESTED_SLOTS) {
table[slot] = {};
- for (const type of NESTED_TYPES[slot]) table[slot][type] = new NestedSlots(doc, edit).keysOf(slot, doc.moddle.create(type));
+ for (const type of NESTED_TYPES[slot]) table[slot][type] = new NestedSlots(edit).keysOf(slot, doc.moddle.create(type));
}
return table;
}
diff --git a/src/adapters/in/report/ModelViewText.ts b/src/adapters/in/report/ModelViewText.ts
index c0b26b5c..81b6efa8 100644
--- a/src/adapters/in/report/ModelViewText.ts
+++ b/src/adapters/in/report/ModelViewText.ts
@@ -1,7 +1,6 @@
import type { ModelView, ViewLane, ViewNode } from '../../../domain/views/model-view.js';
-import { truncate } from '../../../domain/views/utils/view.js';
import { renderProblems } from './utils/result-lines.js';
-import { annotationLine, DOC_MAX, flagValue, flowText, INDENT, messageFlowLine, quote, scalar, vendorFlags } from './utils/view-lines.js';
+import { annotationLine, elementFlags, elementLine, flagValue, flowText, INDENT, messageFlowLine, quote, vendorFlags } from './utils/view-lines.js';
/**
* The model view as text, the default output of `show`: one line per node with its flows inline
@@ -85,24 +84,8 @@ export class ModelViewText {
}
private static nodeLine(node: ViewNode): string {
- const parts = [node.kind, node.id];
- if (node.name) parts.push(quote(node.name));
- const flags = ModelViewText.nodeFlags(node);
- if (flags.length) parts.push(`[${flags.join(', ')}]`);
- let line = parts.join(' ');
+ let line = elementLine(node, elementFlags(node, { vendor: vendorFlags(node) }));
if (node.outgoing.length) line += ` -> ${node.outgoing.map(flowText).join(', ')}`;
return node.unreachable ? `! unreachable: ${line}` : line;
}
-
- private static nodeFlags(node: ViewNode): string[] {
- const flags: string[] = [];
- if (node.lane) flags.push(`lane=${node.lane}`);
- if (node.trigger) flags.push(node.trigger);
- if (node.nonInterrupting) flags.push('non-interrupting');
- if (node.expanded !== undefined) flags.push(node.expanded ? 'expanded' : 'collapsed');
- for (const [key, value] of Object.entries(node.props ?? {})) flags.push(`${key}=${scalar(value)}`);
- flags.push(...vendorFlags(node));
- if (node.documentation) flags.push(`doc: ${quote(truncate(node.documentation, DOC_MAX))}`);
- return flags;
- }
}
diff --git a/src/adapters/in/report/MutationReportBuilder.ts b/src/adapters/in/report/MutationReportBuilder.ts
index a8c67f89..69d7672b 100644
--- a/src/adapters/in/report/MutationReportBuilder.ts
+++ b/src/adapters/in/report/MutationReportBuilder.ts
@@ -1,5 +1,5 @@
import type { MutationResult } from '../../../domain/batch/types.js';
-import type { Warning } from '../../../domain/document/CliError.js';
+import type { Finding } from '../../../domain/document/Finding.js';
import type { MutationReport } from './command-report.js';
import { layoutWarnings } from './utils/findings.js';
@@ -7,13 +7,13 @@ export class MutationReportBuilder {
constructor(private readonly result: MutationResult) {}
/** Without those the validator reports too: the validator's has the richer hint. */
- opWarnings(): Warning[] {
+ opWarnings(): Finding[] {
const validationKeys = new Set(this.result.validation.warnings.map(MutationReportBuilder.findingKey));
return this.result.changes.warnings.filter((warning) => !validationKeys.has(MutationReportBuilder.findingKey(warning)));
}
/** What --strict counts. */
- warnings(): Warning[] {
+ warnings(): Finding[] {
return [...this.opWarnings(), ...this.result.validation.warnings, ...layoutWarnings(this.result.layout)];
}
@@ -70,7 +70,7 @@ export class MutationReportBuilder {
}
/** An event-gateway finding is about one branch: another target is another finding. */
- private static findingKey(warning: Warning): string {
+ private static findingKey(warning: Finding): string {
return `${warning.code}|${warning.element ?? ''}${warning.code === 'W_EVENT_GATEWAY_TARGET' ? `|${warning.related?.[0] ?? ''}` : ''}`;
}
}
diff --git a/src/domain/commands/OpsSchemaBuilder.ts b/src/adapters/in/report/OpsSchemaBuilder.ts
similarity index 85%
rename from src/domain/commands/OpsSchemaBuilder.ts
rename to src/adapters/in/report/OpsSchemaBuilder.ts
index b808f91a..bed65db8 100644
--- a/src/domain/commands/OpsSchemaBuilder.ts
+++ b/src/adapters/in/report/OpsSchemaBuilder.ts
@@ -1,9 +1,9 @@
-import { ALIAS } from './utils/aliases.js';
-import { OP_NAMES } from './utils/op-names.js';
-import type { FieldSpec } from './utils/field-specs.js';
-import { BRANCH_FIELDS, NODE_FIELDS } from './utils/op-fields.js';
-import { defaultRule } from './utils/schema-rules.js';
-import { EditCommandDefinitions } from './definitions/EditCommandDefinitions.js';
+import { ALIAS } from '../../../domain/commands/utils/aliases.js';
+import { COMMAND_NAMES } from '../../../domain/commands/utils/command-names.js';
+import type { FieldSpec } from '../../../domain/commands/utils/field-specs.js';
+import { BRANCH_FIELDS, NODE_FIELDS } from '../../../domain/commands/utils/op-fields.js';
+import { defaultRule } from '../../../domain/commands/utils/schema-rules.js';
+import { EditCommandDefinitions } from '../../../domain/commands/definitions/EditCommandDefinitions.js';
/** Builds the JSON Schema of the `bpmn apply` ops format from the op field specs. */
class OpsSchemaBuilder {
@@ -45,7 +45,7 @@ class OpsSchemaBuilder {
}
build(): Record {
- const opRefs = OP_NAMES.map((opName) => ({ $ref: `#/$defs/${opName}` }));
+ const commandRefs = COMMAND_NAMES.map((commandName) => ({ $ref: `#/$defs/${commandName}` }));
return {
$schema: 'https://json-schema.org/draft/2020-12/schema',
$id: 'https://bpmn-cli/schemas/ops.json',
@@ -56,7 +56,7 @@ class OpsSchemaBuilder {
{ type: 'object', properties: { $schema: { type: 'string' }, ops: { type: 'array', minItems: 1, items: { $ref: '#/$defs/op' } } }, required: ['ops'], additionalProperties: false },
],
$defs: {
- op: { oneOf: opRefs },
+ op: { oneOf: commandRefs },
...Object.fromEntries(EditCommandDefinitions.all().map((definition) => [definition.name, this.objectSchema(definition.fields, { op: definition.name, description: definition.description, allOf: definition.schemaRules() })])),
branch: this.objectSchema(BRANCH_FIELDS, { description: 'One branch of a split: flow options of the gateway -> first node flow, then the nodes.', allOf: [defaultRule()] }),
node: this.objectSchema(NODE_FIELDS, { description: 'A node inside a split branch: an add op without placement (it is chained after the previous node).', allOf: [defaultRule()] }),
diff --git a/src/adapters/in/report/ShownText.ts b/src/adapters/in/report/ShownText.ts
index c0ae1f39..2d2fbabd 100644
--- a/src/adapters/in/report/ShownText.ts
+++ b/src/adapters/in/report/ShownText.ts
@@ -5,14 +5,22 @@ import { DetailText } from './DetailText.js';
import { LayoutText } from './LayoutText.js';
import { ModelViewText } from './ModelViewText.js';
+type ViewOfKind = Extract['view'];
+
+const RENDERERS: { [Kind in ShownView['kind']]: (view: ViewOfKind) => string } = {
+ model: (view) => new ModelViewText(view).text(),
+ detail: (view) => new DetailText(view).text(),
+ context: (view) => new ContextText(view).text(),
+ around: (view) => new AroundText(view).text(),
+ layout: (view) => new LayoutText(view).text(),
+};
+
/** `bpmn show`: whichever view was asked for, as text. */
export class ShownText {
constructor(private readonly shown: ShownView) {}
text(): string {
- const shown = this.shown;
- const text =
- shown.kind === 'layout' ? new LayoutText(shown.view).text() : shown.kind === 'detail' ? new DetailText(shown.view).text() : shown.kind === 'context' ? new ContextText(shown.view).text() : shown.kind === 'around' ? new AroundText(shown.view).text() : new ModelViewText(shown.view).text();
- return text.replace(/\n$/, '');
+ const render = RENDERERS[this.shown.kind] as (view: ShownView['view']) => string;
+ return render(this.shown.view).replace(/\n$/, '');
}
}
diff --git a/src/adapters/in/report/WarningLines.ts b/src/adapters/in/report/WarningLines.ts
index 8b7c1ffd..f0bc09b7 100644
--- a/src/adapters/in/report/WarningLines.ts
+++ b/src/adapters/in/report/WarningLines.ts
@@ -1,6 +1,6 @@
-import type { Warning } from '../../../domain/document/CliError.js';
+import type { Finding } from '../../../domain/document/Finding.js';
import { renderProblems } from './utils/result-lines.js';
-import { FLOOD_MIN, floodKey, idsText, validatorOf, validatorTag, warningTag } from './utils/findings.js';
+import { FLOOD_MIN, floodKey, idsText, validatorTag, warningTag } from './utils/findings.js';
/** Never flooded: each hint is the command for that one element. */
const NEVER_FLOOD = new Set(['W_FLOW_ID_STALE']);
@@ -15,12 +15,12 @@ export class WarningLines {
* FLOOD_MIN or more warnings of one code become one line at the place of the first;
* an advisory validator's findings group by code and message.
*/
- lines(warnings: readonly Warning[]): string[] {
- const keyOf = (warning: Warning): string => {
- const validator = validatorOf(warning);
+ lines(warnings: readonly Finding[]): string[] {
+ const keyOf = (warning: Finding): string => {
+ const validator = warning.validator;
return validator && this.advisory.has(validator) ? `${warningTag(warning)}${warning.code}\n${warning.message}` : floodKey(warning);
};
- const groups = new Map();
+ const groups = new Map();
for (const warning of warnings) groups.set(keyOf(warning), [...(groups.get(keyOf(warning)) ?? []), warning]);
const out: string[] = [];
const done = new Set();
@@ -44,17 +44,17 @@ export class WarningLines {
* line naming the most common message.
* Advisory rule names carry no E_ / W_, so their errors read `[bpmnlint error]`.
*/
- problems(problems: readonly Warning[]): string {
- const isAdvisory = (problem: Warning): boolean => {
- const validator = validatorOf(problem);
+ problems(problems: readonly Finding[]): string {
+ const isAdvisory = (problem: Finding): boolean => {
+ const validator = problem.validator;
return !!validator && this.advisory.has(validator);
};
- const keyOf = (problem: Warning): string | undefined => {
+ const keyOf = (problem: Finding): string | undefined => {
if (!isAdvisory(problem) || NO_RULE.has(problem.code)) return undefined;
- return `${validatorOf(problem)}\n${problem.code}\n${problem.hint ?? ''}`;
+ return `${problem.validator}\n${problem.code}\n${problem.hint ?? ''}`;
};
- const tag = (problem: Warning): string => (isAdvisory(problem) ? warningTag(problem) : validatorTag(problem));
- const groups = new Map();
+ const tag = (problem: Finding): string => (isAdvisory(problem) ? warningTag(problem) : validatorTag(problem));
+ const groups = new Map();
for (const problem of problems) {
const key = keyOf(problem);
if (key) groups.set(key, [...(groups.get(key) ?? []), problem]);
@@ -70,7 +70,7 @@ export class WarningLines {
}
if (done.has(key!)) continue;
done.add(key!);
- const byMessage = new Map();
+ const byMessage = new Map();
for (const member of group) byMessage.set(member.message, [...(byMessage.get(member.message) ?? []), member]);
const [message, same] = [...byMessage].reduce((best, candidate) => (candidate[1].length > best[1].length ? candidate : best));
const others = byMessage.size - 1;
@@ -81,7 +81,7 @@ export class WarningLines {
return out.join('\n');
}
- private static warningLine(warning: Warning): string {
+ private static warningLine(warning: Finding): string {
return `warning ${warningTag(warning)}${warning.code}${warning.element ? ` ${warning.element}` : ''}: ${warning.message}${warning.hint ? ` (${warning.hint})` : ''}`;
}
}
diff --git a/src/adapters/in/report/command-report.ts b/src/adapters/in/report/command-report.ts
index 1e50e8f2..b5327b27 100644
--- a/src/adapters/in/report/command-report.ts
+++ b/src/adapters/in/report/command-report.ts
@@ -3,8 +3,8 @@
* a host that embeds the library hands its agent exactly what the CLI would print.
*/
import type { LayoutProblem } from '../../../domain/diagram/utils/problems.js';
-import type { FormatResult } from '../../../domain/diagram/DiagramCommandService.js';
-import type { Warning } from '../../../domain/document/CliError.js';
+import type { DiagramCommandResult } from '../../../domain/diagram/commands/DiagramCommands.js';
+import type { Finding } from '../../../domain/document/Finding.js';
import type { CheckResult } from '../../../application/port/in/ValidateDocumentUseCase.js';
import type { ConventionsReport } from '../../../domain/batch/types.js';
import type { LayoutStatus } from '../../../domain/diagram/types.js';
@@ -19,8 +19,8 @@ import type { ModelView } from '../../../domain/views/model-view.js';
* is only counted (`bpmn validate` lists it).
*/
export interface WarningReport {
- added: Warning[];
- resolved: Warning[];
+ added: Finding[];
+ resolved: Finding[];
preexistingCount: number;
}
@@ -52,8 +52,8 @@ export interface MutationReport {
/** The JSON of `bpmn validate`. */
export interface ValidationReport {
ok: boolean;
- errors: Warning[];
- warnings: Warning[];
+ errors: Finding[];
+ warnings: Finding[];
platform?: PlatformSummary;
/** every finding of each validator that ran; they are among errors / warnings too */
validators?: ValidatorReport[];
@@ -74,13 +74,13 @@ export interface MutationSummary {
changed: string[];
renamed?: Record;
removed: string[];
- forced?: Warning[];
+ forced?: Finding[];
/** preexistingCount includes the advisory validators' findings the file already had */
- warnings: { added: Warning[]; resolvedCount: number; preexistingCount: number };
+ warnings: { added: Finding[]; resolvedCount: number; preexistingCount: number };
/** the findings validators left out of the comparison, per validator and reason */
leftOut?: Array<{ validator: string; reason: string; count: number }>;
/** without a drawing before, only how many problems the result has */
layout: { status: 'ok' | 'skipped'; mode?: 'full' | 'incremental'; score?: { before?: number; after: number }; added?: LayoutProblem[]; problems?: number };
- format?: FormatResult[];
+ format?: DiagramCommandResult[];
conventions?: ConventionsReport;
}
diff --git a/src/adapters/in/report/guide.ts b/src/adapters/in/report/guide.ts
index ee56b691..2162622c 100644
--- a/src/adapters/in/report/guide.ts
+++ b/src/adapters/in/report/guide.ts
@@ -2,10 +2,10 @@
* The hand-written tables of `bpmn kinds` and `bpmn guide`; everything else they print is
* derived from the live tables, so the text cannot drift from the code.
*/
-import { EXIT_CODES } from '../../../domain/document/CliError.js';
+import { EXIT_CODES } from '../../../domain/document/BpmnError.js';
import type { TriggerOptions } from '../../../domain/commands/types.js';
-import type { Trigger } from '../../../domain/document/utils/kinds.js';
-import { FORMAT_OP_NAMES } from '../../../domain/commands/utils/op-names.js';
+import type { Trigger } from '../../../domain/document/Kinds.js';
+import { DIAGRAM_COMMAND_NAMES } from '../../../domain/commands/utils/command-names.js';
import { triggerFlagUsage } from './utils/option-flags.js';
export interface ErrorDoc {
@@ -75,7 +75,7 @@ const SCOPE_AND_PLACEMENT_ERRORS: ErrorDoc[] = [
const FORMAT_OPERATION_ERRORS: ErrorDoc[] = [
{ code: 'E_NO_MATCH', meaning: 'A selector of a format operation names nothing: no sequence-flow path leads from --path to (over the --via flows), no element of the --kind is drawn, or nothing lies on the --branch (its flow leads straight to the join). `bpmn render`: a crop (--around, --path, --scope, ids) of which nothing is drawn on the plane (no shape, or only on another plane), or a --scope on an empty plane.', fix: 'Check the direction and the --via order (`bpmn show ` lists the flows of every node), the kind name (`bpmn find "" --kind `), or name a flow that starts a branch with its own nodes. render: drop --plane or pass the plane the message names; `bpmn layout ` draws elements without a shape.' },
{ code: 'E_NOT_PARTICIPANT', meaning: '`order` with pools: a listed id is a participant, but not one of the given collaboration.', fix: 'List the participants of that collaboration (the candidates are listed): `bpmn order `.' },
- { code: 'E_NO_DIAGRAM', meaning: `The file has no diagram (DI), so a format operation (${FORMAT_OP_NAMES.join(', ')}) has nothing to work on.`, fix: 'Draw it first with `bpmn layout `, or drop --no-layout (a file without diagram is then drawn in full before the format operation runs).' },
+ { code: 'E_NO_DIAGRAM', meaning: `The file has no diagram (DI), so a format operation (${DIAGRAM_COMMAND_NAMES.join(', ')}) has nothing to work on.`, fix: 'Draw it first with `bpmn layout `, or drop --no-layout (a file without diagram is then drawn in full before the format operation runs).' },
{ code: 'E_NO_SHAPE', meaning: 'The element exists but has no shape or connection in the diagram (added with --no-layout, a flow whose ends a --no-layout write changed, a process that is drawn as its pool, an element outside the drawn root).', fix: 'For a pool use the participant id, not the process id. A flow: `bpmn route ` draws its line. Else the next model edit without --no-layout draws it (`bpmn layout ` redraws all).' },
{ code: 'E_DIFFERENT_DIAGRAM', meaning: '`place` / `align`: the element and its reference are drawn on different diagrams (the content of a collapsed sub-process has its own).', fix: 'Use a reference on the same diagram, or expand the sub-process first (`bpmn set expanded=true`).' },
{ code: 'E_LEAVES_CONTAINER', meaning: '`place` / `align` would move a node out of its lane, pool or expanded sub-process: its centre would leave the frame, its box would start more than 10 px into the header of its lane / pool (before the border of a sub-process), the reference is the node\'s own frame (a node is not placed beside the sub-process it lives in), or the reference lies outside the node\'s sub-process and the node would land outside it (a sub-process grows towards a reference inside it, never out to one outside). `align` leaves out (noted) a member only a selector (--kind / --path / --branch) named.', fix: 'Change the membership first (`bpmn move --lane `; in a batch a move op before the place op; the hint names the lane at the target position), or choose a reference inside the frame.' },
@@ -116,7 +116,6 @@ const EVENT_AND_RETYPE_ERRORS: ErrorDoc[] = [
const WRITE_TIME_ERRORS: ErrorDoc[] = [
{ code: 'E_VALIDATION', meaning: 'The change would introduce structural errors, or errors of a validator (a host\'s validator in library use; the project\'s bpmnlint only when a library host asked for that, lintBlocks), so the file was not written (the new errors are listed in the details, a validator\'s with its name in brackets; errors the file already had do not block, see W_PREEXISTING_ERROR). Validators check the result after the layout, i.e. exactly what would be written.', fix: 'Read the errors in the refusal: the lines under it (JSON `error.errors`, library `err.details.errors`), each with its code and hint, a validator\'s with its name in brackets. The file is unchanged, so `bpmn validate ` does not show them. Change the edit so that it does not add them, e.g. make it in one transaction (`bpmn apply ops.json`) that ends in a valid model, or pass --force (library: force: true) to write anyway.' },
{ code: 'E_VALIDATOR_FAILED', meaning: 'A validator (MutationOptions.validators / checkFile validators in library use) threw or did not return an array of findings; nothing was written.', fix: 'Fix the validator or the input it cannot read, or run without it.' },
- { code: 'E_LAYOUT_*', meaning: 'The layout engine could not draw the model (e.g. E_LAYOUT_ERROR); the suffix is the layouter\'s own code, the message names the element.', fix: 'Check the element named in the message (unsupported kind, broken flow graph), or write without DI using --no-layout and run `bpmn layout` later.' },
{ code: 'E_LAYOUT_INCREMENTAL', meaning: '`--layout incremental` was requested and the incremental layout (keep the drawing, place what is new) failed; nothing was written.', fix: 'Retry with --layout full (redraws the whole diagram) or --no-layout (writes without updating the diagram), and report the command and file.' },
{ code: 'E_INTERNAL', meaning: 'An unexpected internal error (exit 70).', fix: 'Re-run with --json (or BPMN_DEBUG=1 for a stack trace) and report the command and file.' },
];
diff --git a/src/domain/document/utils/conventions-text.ts b/src/adapters/in/report/utils/conventions-text.ts
similarity index 87%
rename from src/domain/document/utils/conventions-text.ts
rename to src/adapters/in/report/utils/conventions-text.ts
index 96f24c0a..3a6c130c 100644
--- a/src/domain/document/utils/conventions-text.ts
+++ b/src/adapters/in/report/utils/conventions-text.ts
@@ -1,11 +1,5 @@
-import type { PrefixSource } from '../ids/IdStyle.js';
-import type { ConventionsView, Undeclared } from '../ids/ConventionsTable.js';
-
-/**
- * Notes event triggers whose required qualified prefix new ids cannot have:
- * they follow the file's style.
- */
-export const UNQUALIFIED_NOTE = "qualified prefix ignored, the file's style (W_CONFIG_IGNORED)";
+import type { PrefixSource } from '../../../../domain/document/ids/IdStyle.js';
+import { UNQUALIFIED_NOTE, type ConventionsView, type Undeclared } from '../../../../domain/document/ids/ConventionsTable.js';
const SOURCE_WORDS: Record = {
lint: 'element-id-naming',
diff --git a/src/adapters/in/report/utils/findings.ts b/src/adapters/in/report/utils/findings.ts
index 17628896..35c74657 100644
--- a/src/adapters/in/report/utils/findings.ts
+++ b/src/adapters/in/report/utils/findings.ts
@@ -1,5 +1,5 @@
import type { LayoutStatus } from '../../../../domain/diagram/types.js';
-import type { Warning } from '../../../../domain/document/CliError.js';
+import type { Finding } from '../../../../domain/document/Finding.js';
import type { ValidatorReport } from '../../../../domain/validation/validators.js';
/** This many warnings of one code (and validator) or more are printed as one line. */
@@ -7,32 +7,26 @@ export const FLOOD_MIN = 3;
export const FLOOD_IDS = 30;
-export function validatorOf(warning: Warning): string | undefined {
- return (warning as Warning & { validator?: string }).validator;
-}
-
-export function validatorTag(warning: Warning): string {
- const name = validatorOf(warning);
- return name ? `[${name}] ` : '';
+export function validatorTag(warning: Finding): string {
+ return warning.validator ? `[${warning.validator}] ` : '';
}
/** Only an advisory validator's errors appear among warnings: they read `[bpmnlint error] `. */
-export function warningTag(warning: Warning): string {
- const name = validatorOf(warning);
- return name && (warning as Warning & { severity?: string }).severity === 'error' ? `[${name} error] ` : validatorTag(warning);
+export function warningTag(warning: Finding): string {
+ return warning.validator && warning.severity === 'error' ? `[${warning.validator} error] ` : validatorTag(warning);
}
export function idsText(ids: string[]): string {
return ids.length > FLOOD_IDS ? `${ids.slice(0, FLOOD_IDS).join(', ')}, +${ids.length - FLOOD_IDS} more` : ids.join(', ');
}
-export function floodKey(warning: Warning): string {
+export function floodKey(warning: Finding): string {
return `${validatorTag(warning)}${warning.code}`;
}
/** `W_DEAD_END Activity_A, W_UNREACHABLE x5 [A, B, C, D, E]` */
-export function findingList(findings: readonly Warning[]): string {
- const groups = new Map();
+export function findingList(findings: readonly Finding[]): string {
+ const groups = new Map();
for (const finding of findings) groups.set(floodKey(finding), [...(groups.get(floodKey(finding)) ?? []), finding]);
return [...groups]
.map(([key, group]) => (group.length === 1 ? `${key}${group[0]!.element ? ` ${group[0]!.element}` : ''}` : `${key} x${group.length} [${idsText(group.map((group) => group.element ?? '?'))}]`))
@@ -43,6 +37,6 @@ export function advisoryOf(reports: readonly ValidatorReport[] | undefined): Set
return new Set((reports ?? []).filter((report) => report.blocks === false).map((report) => report.name));
}
-export function layoutWarnings(layout: LayoutStatus): Warning[] {
+export function layoutWarnings(layout: LayoutStatus): Finding[] {
return layout.warnings.map((warning) => ({ code: `W_LAYOUT_${warning.code}`, message: warning.message, element: warning.elementId }));
}
diff --git a/src/adapters/in/report/utils/result-lines.ts b/src/adapters/in/report/utils/result-lines.ts
index e25f1b84..08bd4004 100644
--- a/src/adapters/in/report/utils/result-lines.ts
+++ b/src/adapters/in/report/utils/result-lines.ts
@@ -1,8 +1,8 @@
import type { LayoutProblem } from '../../../../domain/diagram/utils/problems.js';
-import type { FormatResult } from '../../../../domain/diagram/DiagramCommandService.js';
+import type { DiagramCommandResult } from '../../../../domain/diagram/commands/DiagramCommands.js';
import type { Change, ChangeSet } from '../../../../domain/document/ChangeSet.js';
import type { LayoutStatus } from '../../../../domain/diagram/types.js';
-import type { Warning } from '../../../../domain/document/CliError.js';
+import type { Finding } from '../../../../domain/document/Finding.js';
import { INDENT, quote } from './view-lines.js';
export type ChangeEntries = Pick;
@@ -30,8 +30,8 @@ export function hasNoEntries(cs: Pick;
+ documentation?: string;
+}
+
/** Identifiers (topics, classes) up to this length stay whole in the around / context views. */
const IMPL_VALUE_MAX = 120;
@@ -39,12 +48,18 @@ export function vendorFlags(item: { attrs?: Record; extensions?:
return flags;
}
+/** What follows a flow's id: `"name" if default`; `fullCondition` keeps the condition as written. */
+export function flowTraits(flow: Omit, fullCondition = false): string[] {
+ const traits: string[] = [];
+ if (flow.name) traits.push(quote(flow.name));
+ if (flow.condition) traits.push(`if ${fullCondition ? flow.condition : truncate(flow.condition, 120)}`);
+ else if (flow.conditionResource) traits.push(`if resource ${flow.conditionResource}`);
+ if (flow.default) traits.push('default');
+ return traits;
+}
+
export function flowText(flow: ViewFlow): string {
- const parts = [flow.id];
- if (flow.name) parts.push(quote(flow.name));
- if (flow.condition) parts.push(`if ${truncate(flow.condition, 120)}`);
- else if (flow.conditionResource) parts.push(`if resource ${flow.conditionResource}`);
- if (flow.default) parts.push('default');
+ const parts = [flow.id, ...flowTraits(flow)];
const flags = [...(flow.language ? [`language=${flow.language}`] : []), ...vendorFlags(flow)];
if (flags.length) parts.push(`[${flags.join(', ')}]`);
return `${flow.target} (${parts.join(' ')})`;
@@ -54,6 +69,28 @@ export function named(id: string, name?: string): string {
return name ? `${id} ${quote(name)}` : id;
}
+/** ` ["name"] []` */
+export function elementLine(element: { kind: string; id: string; name?: string }, flags: string[]): string {
+ return `${element.kind} ${named(element.id, element.name)}${flags.length ? ` [${flags.join(', ')}]` : ''}`;
+}
+
+/**
+ * An element's flags in the order every view prints them: `leading`, lane, trigger, state,
+ * `afterState`, props, `vendor` (its attributes or its implementation), documentation.
+ */
+export function elementFlags(element: ElementFlagSource, extra: { leading?: string[]; afterState?: string[]; vendor?: string[] } = {}): string[] {
+ const flags = [...(extra.leading ?? [])];
+ if (element.lane) flags.push(`lane=${element.lane}`);
+ if (element.trigger) flags.push(element.trigger);
+ if (element.nonInterrupting) flags.push('non-interrupting');
+ if (element.expanded !== undefined) flags.push(element.expanded ? 'expanded' : 'collapsed');
+ flags.push(...(extra.afterState ?? []));
+ for (const [key, value] of Object.entries(element.props ?? {})) flags.push(`${key}=${scalar(value)}`);
+ flags.push(...(extra.vendor ?? []));
+ if (element.documentation) flags.push(`doc: ${quote(truncate(element.documentation, DOC_MAX))}`);
+ return flags;
+}
+
/** `messageFlow ["name"]: ["name"] -> ["name"] [message M]` */
export function messageFlowLine(mf: ViewMessageFlow): string {
const head = `messageFlow ${mf.id}${mf.name ? ` ${quote(mf.name)}` : ''}`;
diff --git a/src/domain/validation/BpmnlintConfigResolution.ts b/src/adapters/out/bpmnlint/BpmnlintConfigResolution.ts
similarity index 98%
rename from src/domain/validation/BpmnlintConfigResolution.ts
rename to src/adapters/out/bpmnlint/BpmnlintConfigResolution.ts
index 03506797..cc685164 100644
--- a/src/domain/validation/BpmnlintConfigResolution.ts
+++ b/src/adapters/out/bpmnlint/BpmnlintConfigResolution.ts
@@ -1,7 +1,7 @@
import { BpmnModdle } from 'bpmn-moddle';
-import type { BpmnlintConfig, BpmnlintEngine, BpmnlintLinter, BpmnlintRuleSetting, BpmnlintSeverity, BpmnlintUnavailable, ResolvedBpmnlintConfig } from './bpmnlint-validator.js';
+import type { BpmnlintConfig, BpmnlintEngine, BpmnlintLinter, BpmnlintRuleSetting, BpmnlintSeverity, BpmnlintUnavailable, ResolvedBpmnlintConfig } from './bpmnlint-contract.js';
import { MODDLE_NOT_A_MAP, moddleEntries } from './utils/bpmnlint.js';
-import { firstLine } from '../document/utils/text.js';
+import { firstLine } from '../../../domain/document/utils/text.js';
const MAX_DEPTH = 16;
diff --git a/src/domain/validation/BpmnlintValidator.ts b/src/adapters/out/bpmnlint/BpmnlintValidator.ts
similarity index 95%
rename from src/domain/validation/BpmnlintValidator.ts
rename to src/adapters/out/bpmnlint/BpmnlintValidator.ts
index be089b73..b702beb7 100644
--- a/src/domain/validation/BpmnlintValidator.ts
+++ b/src/adapters/out/bpmnlint/BpmnlintValidator.ts
@@ -1,10 +1,10 @@
import { BpmnModdle, type ImportWarning } from 'bpmn-moddle';
-import { isDecisionLinkWarning, reportedImportWarnings } from '../editing/utils/decision-link-warnings.js';
-import type { BpmnlintEngine, BpmnlintUnavailable, ResolvedBpmnlintConfig } from './bpmnlint-validator.js';
+import { isDecisionLinkWarning, reportedImportWarnings } from '../../../domain/editing/utils/decision-link-warnings.js';
+import type { BpmnlintEngine, BpmnlintUnavailable, ResolvedBpmnlintConfig } from './bpmnlint-contract.js';
import { bpmnlintUnavailableFindings } from './utils/bpmnlint.js';
import { LintRun } from './LintRun.js';
-import type { NamedValidator, ValidatorContext, ValidatorFinding } from './validators.js';
-import { firstLine } from '../document/utils/text.js';
+import type { NamedValidator, ValidatorContext, ValidatorFinding } from '../../../domain/validation/validators.js';
+import { firstLine } from '../../../domain/document/utils/text.js';
export const BPMNLINT_VALIDATOR = 'bpmnlint';
diff --git a/src/domain/document/ids/IdConventionsReader.ts b/src/adapters/out/bpmnlint/IdConventionsReader.ts
similarity index 85%
rename from src/domain/document/ids/IdConventionsReader.ts
rename to src/adapters/out/bpmnlint/IdConventionsReader.ts
index 81d4ad12..b0a535f6 100644
--- a/src/domain/document/ids/IdConventionsReader.ts
+++ b/src/adapters/out/bpmnlint/IdConventionsReader.ts
@@ -1,34 +1,13 @@
-import type { BpmnlintRuleSetting } from './bpmnlint-rule-setting.js';
-import { closestKey } from '../utils/fuzzy.js';
-import { isUsablePrefix } from '../utils/slug.js';
-import { KINDS, TRIGGER_TYPES } from '../utils/kinds.js';
-import { createModdle } from '../utils/moddle.js';
-import type { ValidatorFinding } from '../validator-finding.js';
+import type { BpmnlintRuleSetting } from '../../../domain/document/ids/bpmnlint-rule-setting.js';
+import type { IdConventions, QualifierMode } from '../../../domain/document/ids/IdConventions.js';
+import { closestKey } from '../../../domain/document/utils/fuzzy.js';
+import { isUsablePrefix } from '../../../domain/document/utils/slug.js';
+import { KINDS, TRIGGERS } from '../../../domain/document/Kinds.js';
+import { createModdle } from '../../../domain/document/utils/moddle.js';
+import type { ValidatorFinding } from '../../../domain/document/Finding.js';
export const ID_NAMING_RULE = '@miragon/rules/element-id-naming';
-export type QualifierMode = 'off' | 'optional' | 'required';
-
-/** The id conventions the core takes, as plain data (from a .bpmnlintrc or a host's table). */
-export interface IdConventions {
- /**
- * the prefix of new ids per BPMN type ('bpmn:ServiceTask': 'serviceTask_') and per event
- * trigger ('bpmn:StartEvent:timer': 'timerStartEvent_'); other types follow the file's style
- */
- prefixes: Record;
- /** under `required` a new event with a trigger takes the qualified prefix (default optional) */
- qualifier?: QualifierMode;
- /** the case of the body after a declared prefix; absent: the file's case */
- case?: 'camelCase' | 'PascalCase' | 'snake_case';
- /** where they come from (`.bpmnlintrc in models`) */
- source?: string;
- /**
- * types and event triggers (`bpmn:EndEvent:escalation`) checked with a prefix new ids cannot
- * follow: they follow the file's style
- */
- ignored?: string[];
-}
-
/** The helpers of the plugin's `naming` namespace that element-id-naming runs. */
export interface IdNamingHelpers {
DEFAULT_PREFIXES: Record;
@@ -156,7 +135,7 @@ export class IdConventionsReader {
if (plain === undefined || !def.triggers) continue;
for (const trigger of def.triggers) {
if (trigger === 'none') continue;
- const event = moddle.create(def.type, { id: 'x', eventDefinitions: [moddle.create(TRIGGER_TYPES[trigger])] });
+ const event = moddle.create(def.type, { id: 'x', eventDefinitions: [moddle.create(TRIGGERS[trigger].definition)] });
const qualified = this.naming.acceptedPrefixes(event, plain, qualifier).find((candidate) => candidate !== plain);
const key = `${def.type}:${trigger}`;
if (isUsablePrefix(qualified)) table[key] = qualified;
diff --git a/src/domain/validation/LintRun.ts b/src/adapters/out/bpmnlint/LintRun.ts
similarity index 97%
rename from src/domain/validation/LintRun.ts
rename to src/adapters/out/bpmnlint/LintRun.ts
index ec6adc9e..2ac55ecb 100644
--- a/src/domain/validation/LintRun.ts
+++ b/src/adapters/out/bpmnlint/LintRun.ts
@@ -1,6 +1,6 @@
-import type { BpmnlintEngine, BpmnlintResolver, BpmnlintRule } from './bpmnlint-validator.js';
-import type { ValidatorContext, ValidatorFinding } from './validators.js';
-import { firstLine } from '../document/utils/text.js';
+import type { BpmnlintEngine, BpmnlintResolver, BpmnlintRule } from './bpmnlint-contract.js';
+import type { ValidatorContext, ValidatorFinding } from '../../../domain/validation/validators.js';
+import { firstLine } from '../../../domain/document/utils/text.js';
/** From the sequence flows themselves, not the incoming / outgoing lists. */
interface FlowEnds {
diff --git a/src/domain/validation/bpmnlint-validator.ts b/src/adapters/out/bpmnlint/bpmnlint-contract.ts
similarity index 91%
rename from src/domain/validation/bpmnlint-validator.ts
rename to src/adapters/out/bpmnlint/bpmnlint-contract.ts
index bc932723..ca5cd8a9 100644
--- a/src/domain/validation/bpmnlint-validator.ts
+++ b/src/adapters/out/bpmnlint/bpmnlint-contract.ts
@@ -45,8 +45,8 @@ export interface BpmnlintEngine {
resolver: BpmnlintResolver;
}
-export type { BpmnlintRuleSetting, BpmnlintSeverity } from '../document/ids/bpmnlint-rule-setting.js';
-import type { BpmnlintRuleSetting } from '../document/ids/bpmnlint-rule-setting.js';
+export type { BpmnlintRuleSetting, BpmnlintSeverity } from '../../../domain/document/ids/bpmnlint-rule-setting.js';
+import type { BpmnlintRuleSetting } from '../../../domain/document/ids/bpmnlint-rule-setting.js';
/** A config, rule, moddle extension or file left out, or bpmnlint that could not be loaded. */
export interface BpmnlintUnavailable {
diff --git a/src/domain/validation/utils/bpmnlint.ts b/src/adapters/out/bpmnlint/utils/bpmnlint.ts
similarity index 94%
rename from src/domain/validation/utils/bpmnlint.ts
rename to src/adapters/out/bpmnlint/utils/bpmnlint.ts
index 9b28908c..ab07f45b 100644
--- a/src/domain/validation/utils/bpmnlint.ts
+++ b/src/adapters/out/bpmnlint/utils/bpmnlint.ts
@@ -1,5 +1,5 @@
-import type { BpmnlintConfig, BpmnlintUnavailable } from '../bpmnlint-validator.js';
-import type { ValidatorFinding } from '../validators.js';
+import type { BpmnlintConfig, BpmnlintUnavailable } from '../bpmnlint-contract.js';
+import type { ValidatorFinding } from '../../../../domain/validation/validators.js';
export const MODDLE_NOT_A_MAP = 'it is not an object of prefix -> descriptor or module path (bpmnlint fails on the whole file with it)';
diff --git a/src/adapters/out/document/DocumentStore.ts b/src/adapters/out/document/DocumentStore.ts
new file mode 100644
index 00000000..c404d632
--- /dev/null
+++ b/src/adapters/out/document/DocumentStore.ts
@@ -0,0 +1,51 @@
+import type { DocumentSource, DocumentText } from '../../../application/port/in/DocumentSource.js';
+import type { FileWriteOptions } from '../../../application/port/in/EditDocumentUseCase.js';
+import type { DocumentPort } from '../../../application/port/out/DocumentPort.js';
+import type { MutationResult } from '../../../domain/batch/types.js';
+import { Doc } from '../../../domain/document/Doc.js';
+import { ioError } from '../../../domain/document/utils/errors.js';
+
+export interface WriteOptions {
+ source?: string;
+ unchanged: boolean;
+ backup?: boolean;
+}
+
+/** Loading, targeting and saving a document; where its bytes live is up to the store. */
+export abstract class DocumentStore implements DocumentPort {
+ async load(source: DocumentSource | DocumentText, opts: { lossless?: boolean; force?: boolean } = {}): Promise {
+ const doc = await this.parse(source);
+ if (opts.lossless) doc.assertLossless(opts);
+ return doc;
+ }
+
+ async text(source: DocumentText): Promise {
+ if ('xml' in source) return source.xml;
+ return 'file' in source ? this.read(source.file) : this.decode(source.bytes, source.name);
+ }
+
+ async target(file: string | undefined, opts: FileWriteOptions & { force?: boolean }): Promise {
+ const target = opts.out ?? file;
+ if (!opts.dryRun && !target) throw ioError('E_NO_FILE', 'No output file given');
+ if (opts.mustNotExist) await this.assertNew(target, opts);
+ return target;
+ }
+
+ async save(result: MutationResult, doc: Doc, target: string | undefined, opts: FileWriteOptions): Promise {
+ if (!opts.dryRun && target) result.written = await this.write(target, result.xml, { source: doc.file, unchanged: result.unchanged, backup: opts.backup });
+ }
+
+ protected abstract read(file: string): Promise;
+
+ protected abstract decode(bytes: Uint8Array, source: string): string;
+
+ protected abstract assertNew(target: string | undefined, opts: { force?: boolean; dryRun?: boolean }): Promise;
+
+ protected abstract write(target: string, xml: string, opts: WriteOptions): Promise;
+
+ private async parse(source: DocumentSource | DocumentText): Promise {
+ if ('doc' in source) return source.doc;
+ if ('xml' in source) return Doc.fromXml(source.xml, source.file);
+ return 'file' in source ? Doc.fromXml(await this.read(source.file), source.file) : Doc.fromXml(this.decode(source.bytes, source.name));
+ }
+}
diff --git a/src/adapters/out/files/BpmnlintLibs.ts b/src/adapters/out/files/BpmnlintLibs.ts
index 11b3efc9..62f210fd 100644
--- a/src/adapters/out/files/BpmnlintLibs.ts
+++ b/src/adapters/out/files/BpmnlintLibs.ts
@@ -1,6 +1,6 @@
import { createRequire } from 'node:module';
-import type { BpmnlintConfig, BpmnlintEngine, BpmnlintLinterClass, BpmnlintResolver } from '../../../domain/validation/bpmnlint-validator.js';
-import type { IdNamingHelpers } from '../../../domain/document/ids/IdConventionsReader.js';
+import type { BpmnlintConfig, BpmnlintEngine, BpmnlintLinterClass, BpmnlintResolver } from '../bpmnlint/bpmnlint-contract.js';
+import type { IdNamingHelpers } from '../bpmnlint/IdConventionsReader.js';
import { engineUnavailable, lazy, PLUGIN, SHIPPED, versionOf, type LoadFailure, type Require } from './utils/bpmnlint.js';
import { firstLine } from '../../../domain/document/utils/text.js';
diff --git a/src/adapters/out/files/BpmnlintLoader.ts b/src/adapters/out/files/BpmnlintLoader.ts
index d49d9ed5..8a32270e 100644
--- a/src/adapters/out/files/BpmnlintLoader.ts
+++ b/src/adapters/out/files/BpmnlintLoader.ts
@@ -1,8 +1,8 @@
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
-import type { BpmnlintConfig, BpmnlintEngine, BpmnlintUnavailable, ResolvedBpmnlintConfig } from '../../../domain/validation/bpmnlint-validator.js';
-import { BpmnlintConfigResolution } from '../../../domain/validation/BpmnlintConfigResolution.js';
-import { bpmnlintUnavailableFindings } from '../../../domain/validation/utils/bpmnlint.js';
+import type { BpmnlintConfig, BpmnlintEngine, BpmnlintUnavailable, ResolvedBpmnlintConfig } from '../bpmnlint/bpmnlint-contract.js';
+import { BpmnlintConfigResolution } from '../bpmnlint/BpmnlintConfigResolution.js';
+import { bpmnlintUnavailableFindings } from '../bpmnlint/utils/bpmnlint.js';
import { BpmnlintLibs } from './BpmnlintLibs.js';
import { ChainResolver, type BpmnlintChain } from './ChainResolver.js';
import { BPMNLINT_CONFIG_FILE } from './ConfigLookup.js';
diff --git a/src/adapters/out/files/ChainResolver.ts b/src/adapters/out/files/ChainResolver.ts
index 51c05b91..3e014cd2 100644
--- a/src/adapters/out/files/ChainResolver.ts
+++ b/src/adapters/out/files/ChainResolver.ts
@@ -1,6 +1,6 @@
import { createRequire } from 'node:module';
import { join, sep } from 'node:path';
-import type { BpmnlintResolver } from '../../../domain/validation/bpmnlint-validator.js';
+import type { BpmnlintResolver } from '../bpmnlint/bpmnlint-contract.js';
import type { BpmnlintLibs } from './BpmnlintLibs.js';
import type { Repo } from './RepoTrust.js';
import { lazy, SHIPPED, SKIP_HINT, versionOf, type Require } from './utils/bpmnlint.js';
diff --git a/src/adapters/out/files/FileDocumentStore.ts b/src/adapters/out/files/FileDocumentStore.ts
index 5144bdbf..24ae8fbf 100644
--- a/src/adapters/out/files/FileDocumentStore.ts
+++ b/src/adapters/out/files/FileDocumentStore.ts
@@ -1,20 +1,19 @@
import { copyFile } from 'node:fs/promises';
import { resolve } from 'node:path';
-import type { LoadDocumentPort } from '../../../application/port/out/LoadDocumentPort.js';
-import type { SaveDocumentPort } from '../../../application/port/out/SaveDocumentPort.js';
+import { DocumentStore, type WriteOptions } from '../document/DocumentStore.js';
import { assertNewFile, decodeXmlBytes, readXml, writeAtomic } from './utils/files.js';
/** Documents as files on disk; every write is UTF-8. */
-export class FileDocumentStore implements LoadDocumentPort, SaveDocumentPort {
- read(file: string): Promise {
+export class FileDocumentStore extends DocumentStore {
+ protected read(file: string): Promise {
return readXml(file);
}
- decode(bytes: Uint8Array, source: string): string {
+ protected decode(bytes: Uint8Array, source: string): string {
return decodeXmlBytes(bytes, source);
}
- async save(target: string, xml: string, opts: { source?: string; unchanged: boolean; backup?: boolean }): Promise {
+ protected async write(target: string, xml: string, opts: WriteOptions): Promise {
const inPlace = !!opts.source && resolve(target) === resolve(opts.source);
if (opts.unchanged && inPlace) return false;
if (opts.backup && opts.source) await FileDocumentStore.backup(opts.source);
@@ -22,7 +21,7 @@ export class FileDocumentStore implements LoadDocumentPort, SaveDocumentPort {
return true;
}
- assertNew(target: string | undefined, opts: { force?: boolean; dryRun?: boolean }): Promise {
+ protected assertNew(target: string | undefined, opts: { force?: boolean; dryRun?: boolean }): Promise {
return assertNewFile(target, opts);
}
diff --git a/src/adapters/out/files/ModdleExtensions.ts b/src/adapters/out/files/ModdleExtensions.ts
index f703ab0d..d1e07f45 100644
--- a/src/adapters/out/files/ModdleExtensions.ts
+++ b/src/adapters/out/files/ModdleExtensions.ts
@@ -1,8 +1,8 @@
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { extname, join } from 'node:path';
-import type { BpmnlintConfig, BpmnlintUnavailable } from '../../../domain/validation/bpmnlint-validator.js';
-import { moddleEntries } from '../../../domain/validation/utils/bpmnlint.js';
+import type { BpmnlintConfig, BpmnlintUnavailable } from '../bpmnlint/bpmnlint-contract.js';
+import { moddleEntries } from '../bpmnlint/utils/bpmnlint.js';
import type { BpmnlintLibs } from './BpmnlintLibs.js';
import { depState, resolveFirst, type Dep } from './utils/dependencies.js';
import { inside, shown } from './utils/paths.js';
diff --git a/src/adapters/out/files/PngRenderer.ts b/src/adapters/out/files/PngRenderer.ts
index f50d08fb..f9a3c521 100644
--- a/src/adapters/out/files/PngRenderer.ts
+++ b/src/adapters/out/files/PngRenderer.ts
@@ -1,7 +1,7 @@
import { readFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { RENDER_MAX_SIZE, type RenderOptions, type RenderProblem, type RenderTile } from '../../../domain/views/render/types.js';
-import { CliError } from '../../../domain/document/CliError.js';
+import { BpmnError } from '../../../domain/document/BpmnError.js';
import { DocRenderer } from '../../../domain/views/render/DocRenderer.js';
export interface PngImage {
@@ -96,9 +96,9 @@ export class PngRenderer {
}
}
- private renderError(message: string, cause: unknown): CliError {
+ private renderError(message: string, cause: unknown): BpmnError {
const reason = cause instanceof Error ? cause.message : String(cause);
- return new CliError('E_RENDER', `${message}: ${reason}`, 'io', {
+ return new BpmnError('E_RENDER', `${message}: ${reason}`, 'io', {
hint: 'Write the SVG instead (`--format svg`), or render a smaller part; report the file if it keeps failing.',
});
}
diff --git a/src/adapters/out/files/ProjectBpmnlint.ts b/src/adapters/out/files/ProjectBpmnlint.ts
index 972e34ce..ba9593ec 100644
--- a/src/adapters/out/files/ProjectBpmnlint.ts
+++ b/src/adapters/out/files/ProjectBpmnlint.ts
@@ -1,8 +1,8 @@
import { dirname, resolve } from 'node:path';
-import type { BpmnlintRuleSetting, BpmnlintUnavailable, ResolvedBpmnlintConfig } from '../../../domain/validation/bpmnlint-validator.js';
-import { BpmnlintValidator, BPMNLINT_VALIDATOR } from '../../../domain/validation/BpmnlintValidator.js';
-import { bpmnlintUnavailableFindings } from '../../../domain/validation/utils/bpmnlint.js';
-import { IdConventionsReader, ID_NAMING_RULE } from '../../../domain/document/ids/IdConventionsReader.js';
+import type { BpmnlintRuleSetting, BpmnlintUnavailable, ResolvedBpmnlintConfig } from '../bpmnlint/bpmnlint-contract.js';
+import { BpmnlintValidator, BPMNLINT_VALIDATOR } from '../bpmnlint/BpmnlintValidator.js';
+import { bpmnlintUnavailableFindings } from '../bpmnlint/utils/bpmnlint.js';
+import { IdConventionsReader, ID_NAMING_RULE } from '../bpmnlint/IdConventionsReader.js';
import type { NamedValidator, Validator, ValidatorFinding } from '../../../domain/validation/validators.js';
import { BpmnlintLibs } from './BpmnlintLibs.js';
import { BpmnlintLoader, type LoadedBpmnlint } from './BpmnlintLoader.js';
diff --git a/src/adapters/out/files/utils/bpmnlint.ts b/src/adapters/out/files/utils/bpmnlint.ts
index 328aca52..8ea78571 100644
--- a/src/adapters/out/files/utils/bpmnlint.ts
+++ b/src/adapters/out/files/utils/bpmnlint.ts
@@ -1,5 +1,5 @@
import { dirname } from 'node:path';
-import type { BpmnlintUnavailable } from '../../../../domain/validation/bpmnlint-validator.js';
+import type { BpmnlintUnavailable } from '../../bpmnlint/bpmnlint-contract.js';
import { shown } from './paths.js';
export const PLUGIN = '@miragon/bpmnlint-plugin-rules';
diff --git a/src/adapters/out/memory/NoDocumentFiles.ts b/src/adapters/out/memory/NoDocumentFiles.ts
index e7c29b0c..03b9f6c5 100644
--- a/src/adapters/out/memory/NoDocumentFiles.ts
+++ b/src/adapters/out/memory/NoDocumentFiles.ts
@@ -1,20 +1,19 @@
-import type { LoadDocumentPort } from '../../../application/port/out/LoadDocumentPort.js';
-import type { SaveDocumentPort } from '../../../application/port/out/SaveDocumentPort.js';
import { ioError } from '../../../domain/document/utils/errors.js';
+import { DocumentStore } from '../document/DocumentStore.js';
/** A document in memory has no file: there is none to read, and none is written or taken. */
-export class NoDocumentFiles implements LoadDocumentPort, SaveDocumentPort {
- async read(file: string): Promise {
+export class NoDocumentFiles extends DocumentStore {
+ protected async read(file: string): Promise {
throw ioError('E_FILE_NOT_FOUND', `Cannot read ${file}: no such document`, { file });
}
- decode(_bytes: Uint8Array, source: string): string {
+ protected decode(_bytes: Uint8Array, source: string): string {
throw ioError('E_FILE_NOT_FOUND', `Cannot read ${source}: no such document`, { file: source });
}
- async save(): Promise {
+ protected async write(): Promise {
throw ioError('E_NO_FILE', 'No output file given');
}
- async assertNew(): Promise {}
+ protected async assertNew(): Promise {}
}
diff --git a/src/api.ts b/src/api.ts
index c0f95b5f..0d41ff81 100644
--- a/src/api.ts
+++ b/src/api.ts
@@ -1,17 +1,16 @@
/**
* The in-memory API: strings in, strings and data out. Each function runs the same code as the
* CLI command it names, without files or environment, so it runs in Node and in the browser.
- * Errors are CliError instances, exactly what the CLI prints with --json.
+ * Errors are BpmnError instances, exactly what the CLI prints with --json.
*/
import type { DebugSink } from './domain/diagram/layout/LayoutDebug.js';
import type { LayoutMetrics } from './domain/diagram/utils/problems.js';
import type { LayoutView } from './domain/views/layout-view.js';
-import type { AroundView, ElementContext } from './domain/views/context.js';
+import type { AroundView, ElementContext } from './domain/views/around-view.js';
import type { ConventionsView, ConventionsXmlOptions } from './domain/document/ids/ConventionsTable.js';
export type { ConventionsXmlOptions } from './domain/document/ids/ConventionsTable.js';
-import type { IdConventions } from './domain/document/ids/IdConventionsReader.js';
+import type { IdConventions } from './domain/document/ids/IdConventions.js';
import type { Doc, NewDocOptions } from './domain/document/Doc.js';
-import type { LayoutEngine } from './domain/diagram/layout/FullLayout.js';
import type { ExtensionInfo } from './domain/document/ExtensionLister.js';
import type { EditCommand } from './domain/commands/types.js';
import type { MutationOptions, MutationResult } from './domain/batch/types.js';
@@ -51,8 +50,6 @@ export type { ShownView, ViewOptions } from './domain/views/ViewRequest.js';
export interface EditOptions {
/** 'auto' (default): keep a hand-made drawing, redraw an engine-owned one; false: no layout */
layout?: boolean | LayoutMode;
- /** layout engine of full redraws: 'clean' (default) or 'auto' (bpmn-auto-layout) */
- engine?: LayoutEngine;
/** write despite a lossy import, new validation errors or content a retype would delete */
force?: boolean;
/** platform profile whose new findings are reported: 'auto' (default), 'c7', 'c8', 'none' */
@@ -104,7 +101,7 @@ function parseXml(xml: string, file?: string): Promise {
export async function applyToXml(xml: string, ops: unknown, opts: EditOptions = {}): Promise {
const list = readOps(ops);
const doc = await loadXml(xml, opts);
- return toEditResult(xml, await new EditDocumentService(NO_PROJECT, NO_FILES, NO_FILES).edit({ ...opts, source: { doc }, commands: list, dryRun: true }));
+ return toEditResult(xml, await new EditDocumentService(NO_PROJECT, NO_FILES).edit({ ...opts, source: { doc }, commands: list, dryRun: true }));
}
/** `bpmn new`: a laid-out document with one empty process. */
@@ -123,7 +120,7 @@ export interface LayoutXmlOptions extends Omit {
export async function layoutXml(xml: string, opts: LayoutXmlOptions = {}): Promise {
RelayoutDiagramService.assertOptions(opts);
const doc = await loadXml(xml, opts);
- return toEditResult(xml, await new RelayoutDiagramService(NO_PROJECT, NO_FILES, NO_FILES).relayout({ ...opts, source: { doc }, dryRun: true }));
+ return toEditResult(xml, await new RelayoutDiagramService(NO_PROJECT, NO_FILES).relayout({ ...opts, source: { doc }, dryRun: true }));
}
export interface ValidateXmlOptions {
@@ -171,7 +168,9 @@ export async function metricsXml(xml: string): Promise {
- return new FindElementsService().find({ doc: await parseXml(xml), text, ...(opts.kind !== undefined ? { kind: opts.kind, validateKind: true } : {}) });
+ const findElements = new FindElementsService();
+ if (opts.kind !== undefined) findElements.assertKind({ kind: opts.kind });
+ return findElements.find({ doc: await parseXml(xml), text, ...(opts.kind !== undefined ? { kind: opts.kind } : {}) });
}
/** `bpmn ext list --json`: the extension elements of an element and its definitions. */
@@ -182,17 +181,17 @@ export async function extensionsXml(xml: string, id: string): Promise {
- return new EditDocumentService(NO_PROJECT, NO_FILES, NO_FILES).edit({ ...opts, source: { doc }, commands: ops, dryRun: true });
+ return new EditDocumentService(NO_PROJECT, NO_FILES).edit({ ...opts, source: { doc }, commands: ops, dryRun: true });
}
/** `bpmn layout` on an in-memory document. */
export function layoutDoc(doc: Doc, opts: LayoutDocOptions = {}): Promise {
- return new RelayoutDiagramService(NO_PROJECT, NO_FILES, NO_FILES).relayout({ ...opts, source: { doc }, dryRun: true });
+ return new RelayoutDiagramService(NO_PROJECT, NO_FILES).relayout({ ...opts, source: { doc }, dryRun: true });
}
/** What `bpmn validate` finds in a document. */
diff --git a/src/application/port/in/DocumentSource.ts b/src/application/port/in/DocumentSource.ts
index 265555d1..6e1dc1e8 100644
--- a/src/application/port/in/DocumentSource.ts
+++ b/src/application/port/in/DocumentSource.ts
@@ -2,7 +2,10 @@ import type { Doc } from '../../../domain/document/Doc.js';
/**
* The document a use case works on:
- * a file it reads through LoadDocumentPort,
+ * a file it reads through the DocumentPort,
* or one already loaded (stdin, a host's model).
*/
export type DocumentSource = { file: string } | { doc: Doc };
+
+/** The text of a document: a file, bytes read elsewhere (stdin) or XML with the `file` it names, if any. */
+export type DocumentText = { file: string } | { bytes: Uint8Array; name: string } | { xml: string; file?: string };
diff --git a/src/application/port/in/FindElementsUseCase.ts b/src/application/port/in/FindElementsUseCase.ts
index 910a5946..2d8e0011 100644
--- a/src/application/port/in/FindElementsUseCase.ts
+++ b/src/application/port/in/FindElementsUseCase.ts
@@ -8,6 +8,9 @@ import type { FindHit } from '../../../domain/views/model-view.js';
export interface FindElementsUseCase {
find(command: FindElementsUseCase.Command): FindElementsUseCase.Result;
+
+ /** An unknown or rejected kind fails with the same codes as `add` instead of matching nothing. */
+ assertKind(command: FindElementsUseCase.KindCommand): void;
}
export declare namespace FindElementsUseCase {
@@ -17,11 +20,13 @@ export declare namespace FindElementsUseCase {
doc: Doc;
text: string;
kind?: string;
-
- /** an unknown or rejected `kind` fails with the same codes as `add` instead of matching nothing */
- validateKind?: boolean;
}
/** The matching elements, in document order. */
type Result = FindHit[];
+
+ /** The kind a search is restricted to. */
+ interface KindCommand {
+ kind: string;
+ }
}
diff --git a/src/application/port/in/ReadDocumentUseCase.ts b/src/application/port/in/ReadDocumentUseCase.ts
index 88f9ba30..18b678ad 100644
--- a/src/application/port/in/ReadDocumentUseCase.ts
+++ b/src/application/port/in/ReadDocumentUseCase.ts
@@ -1,4 +1,5 @@
import type { Doc } from '../../../domain/document/Doc.js';
+import type { DocumentText } from './DocumentSource.js';
/** Opens a document from its file, from bytes read elsewhere (stdin) or from its XML text. */
export interface ReadDocumentUseCase {
@@ -12,7 +13,7 @@ export declare namespace ReadDocumentUseCase {
interface Command {
/** `xml` with the `file` it names (`doc.file`), if any */
- source: { file: string } | { bytes: Uint8Array; name: string } | { xml: string; file?: string };
+ source: DocumentText;
/**
* E_IMPORT_LOSSY when bpmn-moddle dropped content, unless `force`
diff --git a/src/application/port/in/ShowConventionsUseCase.ts b/src/application/port/in/ShowConventionsUseCase.ts
index 9bd6e564..b326c7bc 100644
--- a/src/application/port/in/ShowConventionsUseCase.ts
+++ b/src/application/port/in/ShowConventionsUseCase.ts
@@ -1,8 +1,5 @@
import type { Doc } from '../../../domain/document/Doc.js';
import type { ConventionsView, ConventionsXmlOptions } from '../../../domain/document/ids/ConventionsTable.js';
-import type { IdConventions, IdNamingHelpers } from '../../../domain/document/ids/IdConventionsReader.js';
-import type { BpmnlintRuleSetting } from '../../../domain/validation/bpmnlint-validator.js';
-import type { ValidatorFinding } from '../../../domain/validation/validators.js';
import type { FileBpmnlintOptions } from '../out/LoadProjectSettingsPort.js';
/**
@@ -15,12 +12,6 @@ export interface ShowConventionsUseCase {
/** The conventions a caller already holds, one row per pattern (no doc: a new file). */
view(command: ShowConventionsUseCase.ViewCommand): ShowConventionsUseCase.Result;
-
- /**
- * The id conventions element-id-naming declares, with a W_CONFIG_IGNORED note for each value
- * new ids cannot follow; `conventions` is undefined where nothing is declared.
- */
- readFromLint(command: ShowConventionsUseCase.ReadFromLintCommand): { conventions?: IdConventions; notes: ValidatorFinding[] };
}
export declare namespace ShowConventionsUseCase {
@@ -41,11 +32,4 @@ export declare namespace ShowConventionsUseCase {
interface ViewCommand extends ConventionsXmlOptions {
doc?: Doc;
}
-
- /** The element-id-naming setting of a .bpmnlintrc to read conventions from. */
- interface ReadFromLintCommand {
- setting: BpmnlintRuleSetting | undefined;
- naming: IdNamingHelpers;
- source?: string;
- }
}
diff --git a/src/application/port/in/ShowModelUseCase.ts b/src/application/port/in/ShowModelUseCase.ts
index 73e32862..f0766c07 100644
--- a/src/application/port/in/ShowModelUseCase.ts
+++ b/src/application/port/in/ShowModelUseCase.ts
@@ -1,7 +1,7 @@
import type { Doc } from '../../../domain/document/Doc.js';
import type { ExtensionInfo } from '../../../domain/document/ExtensionLister.js';
import type { El } from '../../../domain/document/utils/moddle.js';
-import type { AroundOptions, AroundView, ElementContext, Implementation } from '../../../domain/views/context.js';
+import type { AroundOptions, AroundView, ElementContext, Implementation } from '../../../domain/views/around-view.js';
import type { LayoutView } from '../../../domain/views/layout-view.js';
import type { ElementDetail, ModelView } from '../../../domain/views/model-view.js';
import type { ShownView, ViewOptions } from '../../../domain/views/ViewRequest.js';
diff --git a/src/application/port/in/ValidateDocumentUseCase.ts b/src/application/port/in/ValidateDocumentUseCase.ts
index 619a18d5..e32c314d 100644
--- a/src/application/port/in/ValidateDocumentUseCase.ts
+++ b/src/application/port/in/ValidateDocumentUseCase.ts
@@ -1,10 +1,7 @@
import type { Doc } from '../../../domain/document/Doc.js';
import type { Platform, PlatformChoice, PlatformInfo } from '../../../domain/document/vendor/PlatformDetection.js';
import type { ValidateOptions, ValidationResult } from '../../../domain/validation/StructuralValidator.js';
-import type { NamedValidator } from '../../../domain/validation/validators.js';
import type { ProfileReport } from '../../../domain/validation/profiles/PlatformProfiles.js';
-import type { BpmnlintValidatorOptions } from '../../../domain/validation/BpmnlintValidator.js';
-import type { BpmnlintConfig, BpmnlintEngine, ResolvedBpmnlintConfig } from '../../../domain/validation/bpmnlint-validator.js';
import type { FileBpmnlintOptions } from '../out/LoadProjectSettingsPort.js';
import type { CheckOptions, CheckResult } from '../../../domain/validation/DocumentCheck.js';
import type { DocumentSource } from './DocumentSource.js';
@@ -30,11 +27,6 @@ export interface ValidateDocumentUseCase {
runProfile(command: ValidateDocumentUseCase.ProfileCommand): ProfileReport;
detectPlatform(command: ValidateDocumentUseCase.DocumentCommand): PlatformInfo;
-
- /** The advisory validator of a resolved bpmnlint configuration. */
- bpmnlintValidator(command: ValidateDocumentUseCase.BpmnlintValidatorCommand): NamedValidator;
-
- resolveBpmnlintConfig(command: ValidateDocumentUseCase.ResolveBpmnlintCommand): Promise;
}
export declare namespace ValidateDocumentUseCase {
@@ -71,19 +63,6 @@ export declare namespace ValidateDocumentUseCase {
/** default: all */
platforms?: readonly Exclude[];
}
-
- /** A bpmnlint engine and the resolved configuration it runs. */
- interface BpmnlintValidatorCommand extends BpmnlintValidatorOptions {
- engine: BpmnlintEngine;
- resolved: ResolvedBpmnlintConfig;
- }
-
- /** A bpmnlint configuration to resolve with an engine. */
- interface ResolveBpmnlintCommand {
- engine: BpmnlintEngine;
- config: BpmnlintConfig;
- moddleExtensions?: Record;
- }
}
export type { CheckOptions, CheckResult };
diff --git a/src/application/port/out/DocumentPort.ts b/src/application/port/out/DocumentPort.ts
new file mode 100644
index 00000000..a10478bf
--- /dev/null
+++ b/src/application/port/out/DocumentPort.ts
@@ -0,0 +1,34 @@
+import type { MutationResult } from '../../../domain/batch/types.js';
+import type { Doc } from '../../../domain/document/Doc.js';
+import type { DocumentSource, DocumentText } from '../in/DocumentSource.js';
+import type { FileWriteOptions } from '../in/EditDocumentUseCase.js';
+
+/**
+ * Where the use cases read their documents from and write their results to:
+ * files for the CLI and `@miragon/bpmn-cli/node`,
+ * none for the in-memory API.
+ */
+export interface DocumentPort {
+
+ /**
+ * The document of a source, its text decoded in the encoding its XML declaration names;
+ * with `lossless`, E_IMPORT_LOSSY when bpmn-moddle dropped content, unless `force`.
+ */
+ load(source: DocumentSource | DocumentText, opts?: { lossless?: boolean; force?: boolean }): Promise;
+
+ /** The XML text of a source, decoded like `load`. */
+ text(source: DocumentText): Promise;
+
+ /**
+ * Where a result goes: `out`, else `file`; E_NO_FILE without either unless `dryRun`.
+ * With `mustNotExist`, E_FILE_EXISTS where it exists, unless force or dryRun.
+ */
+ target(file: string | undefined, opts: FileWriteOptions & { force?: boolean }): Promise;
+
+ /**
+ * Writes the result to `target` at once (nothing on `dryRun`) and sets `result.written`:
+ * a failed write leaves the old file, an unchanged document is not written back over its own file,
+ * with `backup` the file is copied to .bak first.
+ */
+ save(result: MutationResult, doc: Doc, target: string | undefined, opts: FileWriteOptions): Promise;
+}
diff --git a/src/application/port/out/LoadDocumentPort.ts b/src/application/port/out/LoadDocumentPort.ts
deleted file mode 100644
index bc0c304a..00000000
--- a/src/application/port/out/LoadDocumentPort.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-/**
- * Reads documents:
- * from files for the CLI and `@miragon/bpmn-cli/node`,
- * from memory for the in-memory API.
- */
-export interface LoadDocumentPort {
-
- /** The text of a file, decoded in the encoding its XML declaration names. */
- read(file: string): Promise;
-
- /**
- * Bytes read elsewhere (stdin), decoded like a file;
- * `source` names them in errors.
- */
- decode(bytes: Uint8Array, source: string): string;
-}
diff --git a/src/application/port/out/LoadProjectSettingsPort.ts b/src/application/port/out/LoadProjectSettingsPort.ts
index f1fd4c44..6e8884f5 100644
--- a/src/application/port/out/LoadProjectSettingsPort.ts
+++ b/src/application/port/out/LoadProjectSettingsPort.ts
@@ -1,6 +1,6 @@
import type { BpmnlintRuleSetting } from '../../../domain/document/ids/bpmnlint-rule-setting.js';
-import type { IdConventions } from '../../../domain/document/ids/IdConventionsReader.js';
-import type { ValidatorFinding } from '../../../domain/document/validator-finding.js';
+import type { IdConventions } from '../../../domain/document/ids/IdConventions.js';
+import type { ValidatorFinding } from '../../../domain/document/Finding.js';
import type { ProjectLint } from '../../../domain/validation/ProjectLint.js';
import type { Validator } from '../../../domain/validation/validators.js';
diff --git a/src/application/port/out/SaveDocumentPort.ts b/src/application/port/out/SaveDocumentPort.ts
deleted file mode 100644
index b628d2ea..00000000
--- a/src/application/port/out/SaveDocumentPort.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-/** Writes documents where a result goes. */
-export interface SaveDocumentPort {
-
- /**
- * Writes `xml` to `target` at once: a failed write leaves the old file.
- * An unchanged document is not written back over its own `source` (no new mtime or sync event);
- * with `backup` the source is copied to .bak first.
- * Returns whether it wrote.
- */
- save(target: string, xml: string, opts: { source?: string; unchanged: boolean; backup?: boolean }): Promise;
-
- /** Throws E_FILE_EXISTS where `target` exists, unless force or dryRun. */
- assertNew(target: string | undefined, opts: { force?: boolean; dryRun?: boolean }): Promise;
-}
diff --git a/src/application/service/CreateDocumentService.ts b/src/application/service/CreateDocumentService.ts
index 7c49556f..aa56b6fc 100644
--- a/src/application/service/CreateDocumentService.ts
+++ b/src/application/service/CreateDocumentService.ts
@@ -1,41 +1,27 @@
import { Doc } from '../../domain/document/Doc.js';
-import { ioError } from '../../domain/document/utils/errors.js';
import { newProcessChange } from '../../domain/document/utils/new-process.js';
import type { CreateDocumentUseCase } from '../port/in/CreateDocumentUseCase.js';
+import type { DocumentPort } from '../port/out/DocumentPort.js';
import type { LoadProjectSettingsPort } from '../port/out/LoadProjectSettingsPort.js';
-import type { SaveDocumentPort } from '../port/out/SaveDocumentPort.js';
-import { EditBatchService } from '../../domain/batch/EditBatchService.js';
-import type { MutationResult } from '../../domain/batch/types.js';
-import type { FileMutationOptions, FileWriteOptions } from '../port/in/EditDocumentUseCase.js';
+import { EditBatch } from '../../domain/batch/EditBatch.js';
export class CreateDocumentService implements CreateDocumentUseCase {
constructor(
private readonly settings: LoadProjectSettingsPort,
- private readonly save: SaveDocumentPort,
+ private readonly documents: DocumentPort,
) {}
/** An existing file fails before the .bpmnlintrc is read. */
async create(command: CreateDocumentUseCase.Command): Promise {
const { destination, ...opts } = command;
if (opts.processId !== undefined) Doc.assertProcessId(opts.processId);
- const target = await this.targetOf(destination, opts);
+ const target = await this.documents.target(destination, opts);
const project = (await this.settings.lint(opts.file ?? target, opts)).applyTo(opts, target);
const conventions = opts.conventions ?? (await this.settings.idConventions(target, opts))?.conventions;
const doc = Doc.create({ processName: opts.processName, processId: opts.processId, executable: opts.executable, target: opts.target, ...(conventions ? { conventions } : {}) }, destination);
- const result = await new EditBatchService(doc, [], project).run(target);
+ const result = await new EditBatch(doc, [], project).run(target);
result.changes.create(newProcessChange(doc, opts.processName));
- await this.write(result, doc, target, opts);
+ await this.documents.save(result, doc, target, opts);
return result;
}
-
- private async targetOf(destination: string | undefined, opts: FileMutationOptions): Promise {
- const target = opts.out ?? destination;
- if (!opts.dryRun && !target) throw ioError('E_NO_FILE', 'No output file given');
- if (opts.mustNotExist) await this.save.assertNew(target, opts);
- return target;
- }
-
- private async write(result: MutationResult, doc: Doc, target: string | undefined, opts: FileWriteOptions): Promise {
- if (!opts.dryRun && target) result.written = await this.save.save(target, result.xml, { source: doc.file, unchanged: result.unchanged, backup: opts.backup });
- }
}
diff --git a/src/application/service/EditDocumentService.ts b/src/application/service/EditDocumentService.ts
index a0fcc207..4e8cb4eb 100644
--- a/src/application/service/EditDocumentService.ts
+++ b/src/application/service/EditDocumentService.ts
@@ -1,49 +1,26 @@
-import { Doc } from '../../domain/document/Doc.js';
-import { ioError } from '../../domain/document/utils/errors.js';
-import type { DocumentSource } from '../port/in/DocumentSource.js';
-import type { EditDocumentUseCase, FileMutationOptions, FileWriteOptions } from '../port/in/EditDocumentUseCase.js';
-import type { LoadDocumentPort } from '../port/out/LoadDocumentPort.js';
+import type { EditDocumentUseCase } from '../port/in/EditDocumentUseCase.js';
+import type { DocumentPort } from '../port/out/DocumentPort.js';
import type { LoadProjectSettingsPort } from '../port/out/LoadProjectSettingsPort.js';
-import type { SaveDocumentPort } from '../port/out/SaveDocumentPort.js';
-import { EditBatchService } from '../../domain/batch/EditBatchService.js';
-import type { MutationResult } from '../../domain/batch/types.js';
+import { EditBatch } from '../../domain/batch/EditBatch.js';
import { TextPreservation, type PreservedText } from '../../domain/document/xml/TextPreservation.js';
export class EditDocumentService implements EditDocumentUseCase {
constructor(
private readonly settings: LoadProjectSettingsPort,
- private readonly load: LoadDocumentPort,
- private readonly save: SaveDocumentPort,
+ private readonly documents: DocumentPort,
) {}
async edit(command: EditDocumentUseCase.Command): Promise {
const { source, commands, ...opts } = command;
- const doc = await this.open(source, opts);
- const target = await this.targetOf(doc, opts);
+ const doc = await this.documents.load(source, { ...opts, lossless: true });
+ const target = await this.documents.target(doc.file, opts);
const project = (await this.settings.lint(opts.file ?? target, opts)).applyTo(opts, target);
- const result = await new EditBatchService(doc, commands, project).run(target);
- await this.write(result, doc, target, opts);
+ const result = await new EditBatch(doc, commands, project).run(target);
+ await this.documents.save(result, doc, target, opts);
return result;
}
preserveText(command: EditDocumentUseCase.PreserveTextCommand): Promise {
return new TextPreservation(command.original, command.baseline, command.next).result();
}
-
- private async open(source: DocumentSource, opts: { force?: boolean }): Promise {
- const doc = 'doc' in source ? source.doc : await Doc.fromXml(await this.load.read(source.file), source.file);
- doc.assertLossless(opts);
- return doc;
- }
-
- private async targetOf(doc: Doc, opts: FileMutationOptions): Promise {
- const target = opts.out ?? doc.file;
- if (!opts.dryRun && !target) throw ioError('E_NO_FILE', 'No output file given');
- if (opts.mustNotExist) await this.save.assertNew(target, opts);
- return target;
- }
-
- private async write(result: MutationResult, doc: Doc, target: string | undefined, opts: FileWriteOptions): Promise {
- if (!opts.dryRun && target) result.written = await this.save.save(target, result.xml, { source: doc.file, unchanged: result.unchanged, backup: opts.backup });
- }
}
diff --git a/src/application/service/FindElementsService.ts b/src/application/service/FindElementsService.ts
index ad0c5c13..88da73d0 100644
--- a/src/application/service/FindElementsService.ts
+++ b/src/application/service/FindElementsService.ts
@@ -1,10 +1,13 @@
import type { FindElementsUseCase } from '../port/in/FindElementsUseCase.js';
import { ElementFinder } from '../../domain/views/ElementFinder.js';
-import { assertKindToken } from '../../domain/document/utils/kinds.js';
+import { assertKindToken } from '../../domain/document/Kinds.js';
export class FindElementsService implements FindElementsUseCase {
find(command: FindElementsUseCase.Command): FindElementsUseCase.Result {
- if (command.validateKind && command.kind !== undefined) assertKindToken(command.kind);
return new ElementFinder(command.doc).find(command.text, command.kind);
}
+
+ assertKind(command: FindElementsUseCase.KindCommand): void {
+ assertKindToken(command.kind);
+ }
}
diff --git a/src/application/service/ReadDocumentService.ts b/src/application/service/ReadDocumentService.ts
index 1f0765b7..08da8223 100644
--- a/src/application/service/ReadDocumentService.ts
+++ b/src/application/service/ReadDocumentService.ts
@@ -1,19 +1,10 @@
-import { Doc } from '../../domain/document/Doc.js';
import type { ReadDocumentUseCase } from '../port/in/ReadDocumentUseCase.js';
-import type { LoadDocumentPort } from '../port/out/LoadDocumentPort.js';
+import type { DocumentPort } from '../port/out/DocumentPort.js';
-/** Decoded in the encoding the XML declaration names, so ISO-8859-1 and Windows-1252 characters survive. */
export class ReadDocumentService implements ReadDocumentUseCase {
- constructor(private readonly load: LoadDocumentPort) {}
+ constructor(private readonly documents: DocumentPort) {}
- async read(command: ReadDocumentUseCase.Command): Promise {
- const doc = await this.parse(command.source);
- if (command.lossless) doc.assertLossless(command);
- return doc;
- }
-
- private async parse(source: ReadDocumentUseCase.Command['source']): Promise {
- if ('xml' in source) return Doc.fromXml(source.xml, source.file);
- return 'file' in source ? Doc.fromXml(await this.load.read(source.file), source.file) : Doc.fromXml(this.load.decode(source.bytes, source.name));
+ read(command: ReadDocumentUseCase.Command): Promise {
+ return this.documents.load(command.source, command);
}
}
diff --git a/src/application/service/RelayoutDiagramService.ts b/src/application/service/RelayoutDiagramService.ts
index 6451a8a7..c5572f40 100644
--- a/src/application/service/RelayoutDiagramService.ts
+++ b/src/application/service/RelayoutDiagramService.ts
@@ -1,34 +1,30 @@
import { Doc } from '../../domain/document/Doc.js';
-import { ioError, usageError } from '../../domain/document/utils/errors.js';
+import { usageError } from '../../domain/document/utils/errors.js';
import { SUB_PROCESS_TYPES } from '../../domain/diagram/utils/expansion.js';
-import { kindLabel } from '../../domain/document/utils/kinds.js';
-import { EditBatchService } from '../../domain/batch/EditBatchService.js';
+import { kindLabel } from '../../domain/document/Kinds.js';
+import { EditBatch } from '../../domain/batch/EditBatch.js';
import { FullLayout, type LayoutResult } from '../../domain/diagram/layout/FullLayout.js';
import { LayoutDebug } from '../../domain/diagram/layout/LayoutDebug.js';
-import type { FileLayoutOptions, LayoutDocOptions, RelayoutDiagramUseCase } from '../port/in/RelayoutDiagramUseCase.js';
-import type { DocumentSource } from '../port/in/DocumentSource.js';
-import type { FileWriteOptions } from '../port/in/EditDocumentUseCase.js';
+import type { LayoutDocOptions, RelayoutDiagramUseCase } from '../port/in/RelayoutDiagramUseCase.js';
import type { MutationResult } from '../../domain/batch/types.js';
-import type { LoadDocumentPort } from '../port/out/LoadDocumentPort.js';
+import type { DocumentPort } from '../port/out/DocumentPort.js';
import type { LoadProjectSettingsPort } from '../port/out/LoadProjectSettingsPort.js';
-import type { SaveDocumentPort } from '../port/out/SaveDocumentPort.js';
/** Ids that are no sub-process are errors, not silent no-ops. */
export class RelayoutDiagramService implements RelayoutDiagramUseCase {
constructor(
private readonly settings: LoadProjectSettingsPort,
- private readonly load: LoadDocumentPort,
- private readonly save: SaveDocumentPort,
+ private readonly documents: DocumentPort,
) {}
async relayout(command: RelayoutDiagramUseCase.Command): Promise {
const { source, ...opts } = command;
RelayoutDiagramService.assertOptions(opts);
- const doc = await this.open(source, opts);
- const target = await this.targetOf(doc, opts);
+ const doc = await this.documents.load(source, { ...opts, lossless: true });
+ const target = await this.documents.target(doc.file, opts);
const project = (await this.settings.lint(opts.file ?? target, opts)).applyTo(opts, target);
const result = project.tidy ? await this.tidy(doc, project, target) : await this.redraw(doc, project, target);
- await this.write(result, doc, target, opts);
+ await this.documents.save(result, doc, target, opts);
return result;
}
@@ -49,7 +45,7 @@ export class RelayoutDiagramService implements RelayoutDiagramUseCase {
}
private tidy(doc: Doc, opts: LayoutDocOptions, target: string | undefined): Promise {
- return new EditBatchService(doc, [{ op: 'tidy' }], { ...opts, layout: 'auto' }).run(target);
+ return new EditBatch(doc, [{ op: 'tidy' }], { ...opts, layout: 'auto' }).run(target);
}
private async redraw(doc: Doc, opts: LayoutDocOptions, target: string | undefined): Promise {
@@ -58,7 +54,7 @@ export class RelayoutDiagramService implements RelayoutDiagramUseCase {
const subs = new Map([...expand, ...collapse].map((id) => [id, doc.require(id, SUB_PROCESS_TYPES, 'sub-process')]));
const both = expand.filter((id) => collapse.includes(id));
if (both.length) throw usageError(`${both.join(', ')} given to both --expand and --collapse`);
- const result = await new EditBatchService(doc, [], { ...opts, layout: 'full', expand, collapse }).run(target);
+ const result = await new EditBatch(doc, [], { ...opts, layout: 'full', expand, collapse }).run(target);
for (const [id, expanded] of [...expand.map((id) => [id, true] as const), ...collapse.map((id) => [id, false] as const)]) {
const sub = subs.get(id)!;
const name = sub.get('name');
@@ -66,21 +62,4 @@ export class RelayoutDiagramService implements RelayoutDiagramUseCase {
}
return result;
}
-
- private async open(source: DocumentSource, opts: { force?: boolean }): Promise {
- const doc = 'doc' in source ? source.doc : await Doc.fromXml(await this.load.read(source.file), source.file);
- doc.assertLossless(opts);
- return doc;
- }
-
- private async targetOf(doc: Doc, opts: FileLayoutOptions): Promise {
- const target = opts.out ?? doc.file;
- if (!opts.dryRun && !target) throw ioError('E_NO_FILE', 'No output file given');
- if (opts.mustNotExist) await this.save.assertNew(target, opts);
- return target;
- }
-
- private async write(result: MutationResult, doc: Doc, target: string | undefined, opts: FileWriteOptions): Promise {
- if (!opts.dryRun && target) result.written = await this.save.save(target, result.xml, { source: doc.file, unchanged: result.unchanged, backup: opts.backup });
- }
}
diff --git a/src/application/service/RenderDiagramService.ts b/src/application/service/RenderDiagramService.ts
index e12bdc44..0bbf0500 100644
--- a/src/application/service/RenderDiagramService.ts
+++ b/src/application/service/RenderDiagramService.ts
@@ -1,21 +1,16 @@
import type { RenderDiagramUseCase } from '../port/in/RenderDiagramUseCase.js';
-import type { LoadDocumentPort } from '../port/out/LoadDocumentPort.js';
+import type { DocumentPort } from '../port/out/DocumentPort.js';
import type { WriteImagePort } from '../port/out/WriteImagePort.js';
export class RenderDiagramService implements RenderDiagramUseCase {
constructor(
- private readonly load: LoadDocumentPort,
+ private readonly documents: DocumentPort,
private readonly images: WriteImagePort,
) {}
async render(command: RenderDiagramUseCase.Command): Promise {
const { source, ...request } = command;
- const xml = await this.textOf(source);
+ const xml = await this.documents.text(source);
return this.images.write({ xml, ...request });
}
-
- private async textOf(source: RenderDiagramUseCase.Command['source']): Promise {
- if ('xml' in source) return source.xml;
- return 'file' in source ? this.load.read(source.file) : this.load.decode(source.bytes, source.name);
- }
}
diff --git a/src/application/service/RunCommandsService.ts b/src/application/service/RunCommandsService.ts
index b5477686..cb02c840 100644
--- a/src/application/service/RunCommandsService.ts
+++ b/src/application/service/RunCommandsService.ts
@@ -3,15 +3,15 @@ import type { ChangeSet } from '../../domain/document/ChangeSet.js';
import type { EditCommand } from '../../domain/commands/types.js';
import { CommandParser } from '../../domain/commands/CommandParser.js';
import { parseOpsJson } from '../../domain/commands/utils/ops-json.js';
-import { ModelCommandService } from '../../domain/batch/ModelCommandService.js';
+import { ModelCommands } from '../../domain/batch/ModelCommands.js';
export class RunCommandsService implements RunCommandsUseCase {
runModelCommand(command: RunCommandsUseCase.ModelCommand): ChangeSet {
- return new ModelCommandService(command.doc).runOp(command.command);
+ return new ModelCommands(command.doc).runCommand(command.command);
}
runModelCommands(command: RunCommandsUseCase.ModelCommands): ChangeSet {
- return new ModelCommandService(command.doc).runBatch(command.commands).changes;
+ return new ModelCommands(command.doc).runBatch(command.commands).changes;
}
parseCommands(command: RunCommandsUseCase.ParseCommand): EditCommand[] {
diff --git a/src/application/service/ShowConventionsService.ts b/src/application/service/ShowConventionsService.ts
index 5a95e8cd..d354ded3 100644
--- a/src/application/service/ShowConventionsService.ts
+++ b/src/application/service/ShowConventionsService.ts
@@ -1,6 +1,4 @@
import { ConventionsTable, type Undeclared } from '../../domain/document/ids/ConventionsTable.js';
-import { IdConventionsReader, type IdConventions } from '../../domain/document/ids/IdConventionsReader.js';
-import type { ValidatorFinding } from '../../domain/validation/validators.js';
import type { ShowConventionsUseCase } from '../port/in/ShowConventionsUseCase.js';
import type { LoadProjectSettingsPort, ProjectIdConventions } from '../port/out/LoadProjectSettingsPort.js';
@@ -24,10 +22,6 @@ export class ShowConventionsService implements ShowConventionsUseCase {
return new ConventionsTable(doc, conventions).view(opts);
}
- readFromLint(command: ShowConventionsUseCase.ReadFromLintCommand): { conventions?: IdConventions; notes: ValidatorFinding[] } {
- return new IdConventionsReader(command.naming, command.source).read(command.setting);
- }
-
private static undeclared(found: ProjectIdConventions | undefined, skipped: boolean): Undeclared {
return skipped ? 'skipped' : !found ? 'no config' : found.unavailable ? 'not loaded' : !found.setting || found.setting.severity === 'off' ? 'off' : 'no prefixes';
}
diff --git a/src/application/service/ShowModelService.ts b/src/application/service/ShowModelService.ts
index 9615c1a2..8970c1ef 100644
--- a/src/application/service/ShowModelService.ts
+++ b/src/application/service/ShowModelService.ts
@@ -7,7 +7,7 @@ import { ElementContextBuilder } from '../../domain/views/ElementContextBuilder.
import { LayoutViewBuilder } from '../../domain/views/LayoutViewBuilder.js';
import { ElementFacts } from '../../domain/views/ElementFacts.js';
import { ExtensionLister, type ExtensionInfo } from '../../domain/document/ExtensionLister.js';
-import type { AroundView, ElementContext, Implementation } from '../../domain/views/context.js';
+import type { AroundView, ElementContext, Implementation } from '../../domain/views/around-view.js';
import type { LayoutView } from '../../domain/views/layout-view.js';
import type { ElementDetail, ModelView } from '../../domain/views/model-view.js';
diff --git a/src/application/service/ValidateDocumentService.ts b/src/application/service/ValidateDocumentService.ts
index 4181da98..82cb4d29 100644
--- a/src/application/service/ValidateDocumentService.ts
+++ b/src/application/service/ValidateDocumentService.ts
@@ -1,26 +1,20 @@
-import { Doc } from '../../domain/document/Doc.js';
import { PlatformDetection, type PlatformInfo } from '../../domain/document/vendor/PlatformDetection.js';
import { PLATFORM_PROFILES, type ProfileReport } from '../../domain/validation/profiles/PlatformProfiles.js';
import { StructuralValidator, type ValidationResult } from '../../domain/validation/StructuralValidator.js';
-import { BpmnlintValidator } from '../../domain/validation/BpmnlintValidator.js';
-import { BpmnlintConfigResolution } from '../../domain/validation/BpmnlintConfigResolution.js';
import { DocumentCheck } from '../../domain/validation/DocumentCheck.js';
-import type { NamedValidator } from '../../domain/validation/validators.js';
-import type { ResolvedBpmnlintConfig } from '../../domain/validation/bpmnlint-validator.js';
-import type { DocumentSource } from '../port/in/DocumentSource.js';
import type { ValidateDocumentUseCase } from '../port/in/ValidateDocumentUseCase.js';
-import type { LoadDocumentPort } from '../port/out/LoadDocumentPort.js';
+import type { DocumentPort } from '../port/out/DocumentPort.js';
import type { LoadProjectSettingsPort } from '../port/out/LoadProjectSettingsPort.js';
export class ValidateDocumentService implements ValidateDocumentUseCase {
constructor(
private readonly settings: LoadProjectSettingsPort,
- private readonly load: LoadDocumentPort,
+ private readonly documents: DocumentPort,
) {}
async validate(command: ValidateDocumentUseCase.Command): Promise {
const { source, ...opts } = command;
- const doc = await this.open(source);
+ const doc = await this.documents.load(source);
const project = (await this.settings.lint(opts.file ?? doc.file, opts)).applyTo(opts, doc.file);
return new DocumentCheck(doc, project).run();
}
@@ -45,18 +39,4 @@ export class ValidateDocumentService implements ValidateDocumentUseCase {
detectPlatform(command: ValidateDocumentUseCase.DocumentCommand): PlatformInfo {
return PlatformDetection.of(command.doc).info();
}
-
- bpmnlintValidator(command: ValidateDocumentUseCase.BpmnlintValidatorCommand): NamedValidator {
- const { engine, resolved, ...opts } = command;
- return new BpmnlintValidator(engine, resolved, opts).named();
- }
-
- resolveBpmnlintConfig(command: ValidateDocumentUseCase.ResolveBpmnlintCommand): Promise {
- const { engine, config, ...opts } = command;
- return new BpmnlintConfigResolution(engine, config, opts).resolve();
- }
-
- private async open(source: DocumentSource): Promise {
- return 'doc' in source ? source.doc : Doc.fromXml(await this.load.read(source.file), source.file);
- }
}
diff --git a/src/cli.ts b/src/cli.ts
index c6e7a232..8c09dfc7 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -9,17 +9,16 @@ import { CreateDocumentService } from './application/service/CreateDocumentServi
import { RelayoutDiagramService } from './application/service/RelayoutDiagramService.js';
import { ValidateDocumentService } from './application/service/ValidateDocumentService.js';
import { ReadDocumentService } from './application/service/ReadDocumentService.js';
-import { ShowModelService } from './application/service/ShowModelService.js';
import { ShowConventionsService } from './application/service/ShowConventionsService.js';
-import { FindElementsService } from './application/service/FindElementsService.js';
import { RenderDiagramService } from './application/service/RenderDiagramService.js';
-import { LayoutDebug } from './domain/diagram/layout/LayoutDebug.js';
+import { ShowModelService } from './application/service/ShowModelService.js';
+import { FindElementsService } from './application/service/FindElementsService.js';
+import { AnalyzeLayoutService } from './application/service/AnalyzeLayoutService.js';
+import { RunCommandsService } from './application/service/RunCommandsService.js';
// dist/cli.js and src/cli.ts both sit one level below package.json
const VERSION = (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }).version;
-if (process.env['BPMN_LAYOUT_DEBUG']) LayoutDebug.setProcessSink((line) => process.stderr.write(`${line}\n`));
-
// a reader that closes early (`bpmn validate x.bpmn | head`) ends the output, not the command
for (const stream of [process.stdout, process.stderr]) {
stream.on('error', (err: NodeJS.ErrnoException) => {
@@ -37,15 +36,19 @@ function exitWhenWritten(): void {
const files = new FileDocumentStore();
const project = new BpmnlintProjectSettings();
+const relayoutDiagram = new RelayoutDiagramService(project, files);
+if (process.env['BPMN_LAYOUT_DEBUG']) relayoutDiagram.setDebugSink({ sink: (line) => process.stderr.write(`${line}\n`) });
const cli = new Cli(VERSION, {
- editDocument: new EditDocumentService(project, files, files),
+ editDocument: new EditDocumentService(project, files),
createDocument: new CreateDocumentService(project, files),
- relayoutDiagram: new RelayoutDiagramService(project, files, files),
+ relayoutDiagram,
validateDocument: new ValidateDocumentService(project, files),
readDocument: new ReadDocumentService(files),
showModel: new ShowModelService(),
showConventions: new ShowConventionsService(project),
findElements: new FindElementsService(),
+ analyzeLayout: new AnalyzeLayoutService(),
+ runCommands: new RunCommandsService(),
renderDiagram: new RenderDiagramService(files, new FileImageWriter()),
});
void cli.run(process.argv).then(() => {
diff --git a/src/domain/batch/EditBatchService.ts b/src/domain/batch/EditBatch.ts
similarity index 86%
rename from src/domain/batch/EditBatchService.ts
rename to src/domain/batch/EditBatch.ts
index 42a76112..ba0198fc 100644
--- a/src/domain/batch/EditBatchService.ts
+++ b/src/domain/batch/EditBatch.ts
@@ -1,17 +1,17 @@
import { LayoutDebug } from '../diagram/layout/LayoutDebug.js';
import { Doc } from '../document/Doc.js';
import { modelError } from '../document/utils/errors.js';
-import { EditContext } from '../document/EditContext.js';
+import { EditSession } from '../document/EditSession.js';
import { reportedImportWarnings } from '../editing/utils/decision-link-warnings.js';
-import { ModelCommandService, type BatchRun } from './ModelCommandService.js';
-import { withoutProfileDuplicates } from '../editing/utils/covers.js';
+import { ModelCommands, type BatchRun } from './ModelCommands.js';
+import { withoutProfileDuplicates } from '../validation/utils/finding-coverage.js';
import type { EditCommand } from '../commands/types.js';
import { PLATFORM_PROFILES } from '../validation/profiles/PlatformProfiles.js';
import type { ValidationResult } from '../validation/StructuralValidator.js';
import { FindingBaseline, type ValidationDelta } from '../validation/FindingBaseline.js';
import { ModelViewBuilder } from '../views/ModelViewBuilder.js';
import { BatchDrawing, type DrawingSegment } from '../diagram/BatchDrawing.js';
-import { toCliError } from '../document/utils/cli-error.js';
+import { toBpmnError } from '../document/utils/bpmn-error.js';
import { utf8Declaration } from '../document/utils/encoding.js';
import { serializeWithout, unkeptEntries, withoutEntries } from '../document/utils/mirror.js';
import { TextPreservation } from '../document/xml/TextPreservation.js';
@@ -24,34 +24,34 @@ import type { LayoutStatus } from '../diagram/types.js';
* keep the file's text. Never touches the file system, so it runs in the browser too.
* Only errors the ops introduce block the write; --force overrides every guard.
*/
-export class EditBatchService {
- private readonly edit: EditContext;
+export class EditBatch {
+ private readonly edit: EditSession;
/** An explicit `opts.platform` also decides the engine rules of the ops (event-gateway bridges). */
constructor(
private readonly doc: Doc,
- private readonly ops: EditCommand[],
+ private readonly commands: EditCommand[],
private readonly opts: MutationOptions = {},
) {
const platformChoice = opts.platform && opts.platform !== 'auto' ? opts.platform : undefined;
- this.edit = new EditContext(doc, { conventions: opts.conventions, platformChoice, keepFlowIds: opts.keepFlowIds });
+ this.edit = new EditSession(doc, { conventions: opts.conventions, platformChoice, keepFlowIds: opts.keepFlowIds });
}
/**
* Runs the batch on the document in memory; `file` is where its result goes. Throws a
- * CliError and leaves nothing half done: the caller keeps its input.
+ * BpmnError and leaves nothing half done: the caller keeps its input.
*/
async run(file = this.doc.file): Promise {
return LayoutDebug.during(this.opts.debug, async () => {
try {
await this.guard();
const asRead = await this.textAsRead();
- const diagram = await BatchDrawing.capture(this.doc, this.ops, this.opts, asRead, EditBatchService.removeElements);
- const findings = await FindingBaseline.capture(this.doc, { platform: this.opts.platform, force: this.opts.force, validators: this.opts.validators, file: this.opts.file ?? this.doc.file, ops: this.ops });
+ const diagram = await BatchDrawing.capture(this.doc, this.commands, this.opts, asRead, EditBatch.removeElements);
+ const findings = await FindingBaseline.capture(this.doc, { platform: this.opts.platform, force: this.opts.force, validators: this.opts.validators, file: this.opts.file ?? this.doc.file, ops: this.commands });
const importWarnings = reportedImportWarnings(this.doc).map((warning) => warning.message.split('\n')[0]!);
const { batch, last } = await this.runCommands(diagram);
- await this.validate(findings, batch);
- const drawn = await diagram.apply(this.edit, batch.ops, batch.changes, last);
+ await this.validateAndDropProfileDuplicates(findings, batch);
+ const drawn = await diagram.apply(this.edit, batch.commands, batch.changes, last);
const xml = await this.keptText(drawn.xml, asRead, batch.changes);
const layout = drawn.layout;
const validation = await findings.onCandidate(xml);
@@ -63,7 +63,7 @@ export class EditBatchService {
}
private static removeElements(doc: Doc, ids: string[]): void {
- new ModelCommandService(doc).runBatch([{ op: 'remove', ids, ifExists: true }]);
+ new ModelCommands(doc).runBatch([{ op: 'remove', ids, ifExists: true }]);
}
/** Loads the platform profiles, a chunk of their own in a browser bundle, before anything runs. */
@@ -75,9 +75,9 @@ export class EditBatchService {
private async runCommands(diagram: BatchDrawing): Promise<{ batch: BatchRun; last?: DrawingSegment }> {
let ran: { batch: BatchRun; last?: DrawingSegment };
try {
- ran = await new ModelCommandService(this.doc, this.edit).runBatchDrawnBetween(this.ops, diagram);
+ ran = await new ModelCommands(this.doc, this.edit).runBatchDrawnBetween(this.commands, diagram);
} catch (err) {
- throw toCliError(err);
+ throw toBpmnError(err);
}
if (!this.opts.force) this.refuseDroppedContent();
return ran;
@@ -128,8 +128,8 @@ export class EditBatchService {
});
}
- private async validate(findings: FindingBaseline, batch: BatchRun): Promise {
- const validation = await findings.afterOps();
+ private async validateAndDropProfileDuplicates(findings: FindingBaseline, batch: BatchRun): Promise {
+ const validation = await findings.afterCommands();
batch.changes.warnings = withoutProfileDuplicates(batch.changes.warnings, validation.warnings);
}
diff --git a/src/domain/batch/ModelCommandService.ts b/src/domain/batch/ModelCommands.ts
similarity index 59%
rename from src/domain/batch/ModelCommandService.ts
rename to src/domain/batch/ModelCommands.ts
index 99eebccb..20d659c3 100644
--- a/src/domain/batch/ModelCommandService.ts
+++ b/src/domain/batch/ModelCommands.ts
@@ -1,36 +1,36 @@
import type { Doc } from '../document/Doc.js';
-import { EditContext, type BatchContext } from '../document/EditContext.js';
+import { EditSession, type BatchContext } from '../document/EditSession.js';
import { usageError } from '../document/utils/errors.js';
-import { type Warning } from '../document/CliError.js';
-import { is, type El } from '../document/utils/moddle.js';
+import type { Finding } from '../document/Finding.js';
+import { enclosingProcess, is, type El } from '../document/utils/moddle.js';
import { ChangeSet } from '../document/ChangeSet.js';
import { AliasTable } from '../commands/AliasTable.js';
-import { OpAliases } from '../commands/OpAliases.js';
-import { isDiagramCommand } from '../commands/utils/op-names.js';
+import { CommandAliases } from '../commands/CommandAliases.js';
+import { isDiagramCommand } from '../commands/utils/command-names.js';
import type { EditCommand } from '../commands/types.js';
import type { BatchDrawing, DrawingSegment } from '../diagram/BatchDrawing.js';
-import { ElementCreationService } from '../editing/ElementCreationService.js';
-import { ConnectionService } from '../editing/ConnectionService.js';
-import { PropertyService } from '../editing/PropertyService.js';
-import { ElementRemovalService } from '../editing/ElementRemovalService.js';
-import { ElementTypeChangeService } from '../editing/ElementTypeChangeService.js';
-import { ElementMoveService } from '../editing/ElementMoveService.js';
-import { ElementOrderService } from '../editing/ElementOrderService.js';
-import { ExtensionElementService } from '../editing/extensions/ExtensionElementService.js';
-import { BranchSplitService } from '../editing/flows/BranchSplitService.js';
-import { IdRenameService } from '../editing/IdRenameService.js';
-import { StaleFlowReport } from '../editing/flows/StaleFlowReport.js';
+import { ElementCreation } from '../editing/elements/ElementCreation.js';
+import { Connections } from '../editing/connections/Connections.js';
+import { Properties } from '../editing/properties/Properties.js';
+import { ElementRemoval } from '../editing/elements/ElementRemoval.js';
+import { ElementTypeChange } from '../editing/elements/ElementTypeChange.js';
+import { ElementMove } from '../editing/elements/ElementMove.js';
+import { ElementOrder } from '../editing/elements/ElementOrder.js';
+import { ExtensionElements } from '../editing/extensions/ExtensionElements.js';
+import { BranchSplit } from '../editing/connections/BranchSplit.js';
+import { IdRename } from '../editing/ids/IdRename.js';
+import { StaleFlowReport } from '../editing/connections/StaleFlowReport.js';
/** The merged changes, the ops with aliases resolved (for layout and format) and alias -> id. */
export interface BatchRun {
changes: ChangeSet;
- ops: EditCommand[];
+ commands: EditCommand[];
aliases: Record;
}
/** The state of the batch that runs. */
interface RunningBatch {
- ops: EditCommand[];
+ commands: EditCommand[];
all: ChangeSet;
table: AliasTable;
resolved: EditCommand[];
@@ -40,23 +40,23 @@ interface RunningBatch {
}
/**
- * Dispatches ops: every CLI command and `apply` run through here, inside one edit context
- * handed to every editing service; without one given, a fresh one for the document.
+ * Dispatches ops: every CLI command and `apply` run through here, inside one edit session
+ * handed to every editing class; without one given, a fresh one for the document.
*/
-export class ModelCommandService {
+export class ModelCommands {
private batch: RunningBatch | undefined;
constructor(
private readonly doc: Doc,
- private readonly edit: EditContext = new EditContext(doc),
+ private readonly edit: EditSession = new EditSession(doc),
) {}
/** Runs one op on its own; stale flow ids and id prefixes are reported at its end. */
- runOp(op: EditCommand): ChangeSet {
+ runCommand(command: EditCommand): ChangeSet {
this.edit.begin();
- const cs = this.step(op);
- new StaleFlowReport(this.doc, this.edit).report(cs, this.edit.flowsBefore);
- new IdRenameService(this.doc, this.edit).reportStalePrefixes(cs);
+ const cs = this.step(command);
+ new StaleFlowReport(this.edit).report(cs, this.edit.flowsBefore);
+ new IdRename(this.edit).reportStalePrefixes(cs);
return cs;
}
@@ -65,9 +65,9 @@ export class ModelCommandService {
* commands get theirs once the model commands before them ran. Stale flow ids and id prefixes
* are reported once, against the state before and after the whole batch.
*/
- runBatch(ops: EditCommand[]): BatchRun {
- this.beginBatch(ops);
- this.runCommands(0, ops.length);
+ runBatch(commands: EditCommand[]): BatchRun {
+ this.beginBatch(commands);
+ this.runCommands(0, commands.length);
this.resolveDiagramCommands();
return this.finishBatch();
}
@@ -77,10 +77,10 @@ export class ModelCommandService {
* command the drawing is brought up to date first, so every diagram command sees the
* commands before it; `last` is then the segment the final layout still has to draw.
*/
- async runBatchDrawnBetween(ops: EditCommand[], drawing: BatchDrawing): Promise<{ batch: BatchRun; last?: DrawingSegment }> {
- const segments = ModelCommandService.segmentsOf(ops);
+ async runBatchDrawnBetween(commands: EditCommand[], drawing: BatchDrawing): Promise<{ batch: BatchRun; last?: DrawingSegment }> {
+ const segments = ModelCommands.segmentsOf(commands);
let last: DrawingSegment | undefined;
- this.beginBatch(ops);
+ this.beginBatch(commands);
for (const [position, segment] of segments.entries()) {
const before = new Set(this.changesSoFar.created.map((created) => this.doc.get(created.id)));
this.runCommands(segment.start, segment.end);
@@ -100,16 +100,16 @@ export class ModelCommandService {
* The batch cut where a model command follows a diagram command: the diagram commands
* of a segment see the drawing of every command before them.
*/
- private static segmentsOf(ops: readonly EditCommand[]): Array> {
+ private static segmentsOf(commands: readonly EditCommand[]): Array> {
const segments: Array> = [];
let start = 0;
- const segment = (end: number) => ({ start, end, model: ops.slice(start, end).some((op) => !isDiagramCommand(op)) });
- ops.forEach((op, index) => {
- if (index === 0 || isDiagramCommand(op) || !isDiagramCommand(ops[index - 1]!)) return;
+ const segment = (end: number) => ({ start, end, model: commands.slice(start, end).some((command) => !isDiagramCommand(command)) });
+ commands.forEach((command, index) => {
+ if (index === 0 || isDiagramCommand(command) || !isDiagramCommand(commands[index - 1]!)) return;
segments.push(segment(index));
start = index;
});
- segments.push(segment(ops.length));
+ segments.push(segment(commands.length));
return segments;
}
@@ -117,10 +117,10 @@ export class ModelCommandService {
* Starts a batch that runCommands works off in steps, so the diagram can be drawn in between.
* Its context records what the batch did so far, so E_NOT_FOUND can explain a missing id.
*/
- private beginBatch(ops: EditCommand[]): void {
+ private beginBatch(commands: EditCommand[]): void {
const table = new AliasTable(this.doc);
const context: BatchContext = { created: [], renamed: new Map(), retyped: new Set(), aliases: () => table.toRecord() };
- this.batch = { ops, all: new ChangeSet(), table, resolved: [...ops], pending: [], context };
+ this.batch = { commands, all: new ChangeSet(), table, resolved: [...commands], pending: [], context };
this.edit.begin();
}
@@ -131,20 +131,20 @@ export class ModelCommandService {
private runCommands(from: number, to: number): void {
const batch = this.openBatch();
this.inBatch(() => {
- for (let opIndex = from; opIndex < to; opIndex++) {
- const op = batch.ops[opIndex]!;
- this.atOp(opIndex, () => {
- if (isDiagramCommand(op)) {
- for (const { key, alias } of new OpAliases(op).references()) batch.table.idOf(alias, opIndex, op, key);
- batch.pending.push(opIndex);
+ for (let commandIndex = from; commandIndex < to; commandIndex++) {
+ const command = batch.commands[commandIndex]!;
+ this.atCommand(commandIndex, () => {
+ if (isDiagramCommand(command)) {
+ for (const { key, alias } of new CommandAliases(command).references()) batch.table.idOf(alias, commandIndex, command, key);
+ batch.pending.push(commandIndex);
return;
}
- const ready = new OpAliases(op).resolve(batch.table, opIndex);
- batch.resolved[opIndex] = ready;
+ const ready = new CommandAliases(command).resolve(batch.table, commandIndex);
+ batch.resolved[commandIndex] = ready;
const cs = this.step(ready);
batch.all.merge(cs);
for (const replacement of cs.replaced) batch.table.rebind(replacement.from, replacement.to);
- for (const binding of cs.bindings) batch.table.define(binding.alias, binding.el, opIndex);
+ for (const binding of cs.bindings) batch.table.define(binding.alias, binding.el, commandIndex);
const context = batch.context;
context.created.push(...cs.created.map((change) => change.id));
for (const rename of cs.renames) {
@@ -168,9 +168,9 @@ export class ModelCommandService {
const pending = batch.pending.splice(0);
this.inBatch(() => {
for (const pendingIndex of pending) {
- this.atOp(pendingIndex, () => {
- const ready = (batch.resolved[pendingIndex] = new OpAliases(batch.ops[pendingIndex]!).resolve(batch.table, pendingIndex));
- for (const id of new OpAliases(ready).referencedIds()) if (!this.doc.get(id)) this.edit.require(id);
+ this.atCommand(pendingIndex, () => {
+ const ready = (batch.resolved[pendingIndex] = new CommandAliases(batch.commands[pendingIndex]!).resolve(batch.table, pendingIndex));
+ for (const id of new CommandAliases(ready).referencedIds()) if (!this.doc.get(id)) this.edit.require(id);
});
}
});
@@ -185,9 +185,9 @@ export class ModelCommandService {
all.bindings = [];
all.replaced = [];
all.warnings = all.warnings.filter((warning) => this.stillHolds(warning));
- new StaleFlowReport(this.doc, this.edit).report(all, this.edit.flowsBefore);
- new IdRenameService(this.doc, this.edit).reportStalePrefixes(all);
- return { changes: all, ops: batch.resolved, aliases: batch.table.toRecord() };
+ new StaleFlowReport(this.edit).report(all, this.edit.flowsBefore);
+ new IdRename(this.edit).reportStalePrefixes(all);
+ return { changes: all, commands: batch.resolved, aliases: batch.table.toRecord() };
}
/** The changes of the batch so far, e.g. the ids created since the drawing was last updated. */
@@ -210,49 +210,47 @@ export class ModelCommandService {
}
/** Every entry names its final id; merge applies renames to earlier ops too. */
- private step(op: EditCommand): ChangeSet {
- const cs = this.dispatch(op);
+ private step(command: EditCommand): ChangeSet {
+ const cs = this.dispatch(command);
this.edit.allocator.reportSuffixed(cs);
cs.rename(this.edit.takeRenames());
return cs;
}
- /** Diagram-only commands change nothing here: DiagramCommandService runs them once the drawing is up to date. */
- private dispatch(op: EditCommand): ChangeSet {
- const doc = this.doc;
- if (isDiagramCommand(op)) return new ChangeSet();
- switch (op.op) {
+ /** Diagram-only commands change nothing here: DiagramCommands runs them once the drawing is up to date. */
+ private dispatch(command: EditCommand): ChangeSet {
+ if (isDiagramCommand(command)) return new ChangeSet();
+ switch (command.op) {
case 'add':
- return new ElementCreationService(doc, this.edit).add(op);
+ return new ElementCreation(this.edit).add(command);
case 'connect':
- return new ConnectionService(doc, this.edit).connect(op);
+ return new Connections(this.edit).connect(command);
case 'set':
- return new PropertyService(doc, this.edit).set(op);
+ return new Properties(this.edit).set(command);
case 'remove':
- return new ElementRemovalService(doc, this.edit).remove(op);
+ return new ElementRemoval(this.edit).remove(command);
case 'retype':
- return new ElementTypeChangeService(doc, this.edit).retype(op);
+ return new ElementTypeChange(this.edit).retype(command);
case 'move':
- return new ElementMoveService(doc, this.edit).move(op);
+ return new ElementMove(this.edit).move(command);
case 'order':
- return new ElementOrderService(doc, this.edit, op).apply();
+ return new ElementOrder(this.edit).reorder(command);
case 'ext':
- return new ExtensionElementService(doc, this.edit).apply(op);
+ return new ExtensionElements(this.edit).apply(command);
case 'split':
- return new BranchSplitService(doc, this.edit).split(op);
+ return new BranchSplit(this.edit).split(command);
default:
- throw usageError(`Unknown operation "${String((op as { op?: unknown }).op)}"`);
+ throw usageError(`Unknown operation "${String((command as { op?: unknown }).op)}"`);
}
}
/** A later op may have added the pool or put a gateway in between. */
- private stillHolds(warning: Warning): boolean {
+ private stillHolds(warning: Finding): boolean {
const doc = this.doc;
const el = warning.element ? doc.get(warning.element) : undefined;
switch (warning.code) {
case 'W_LANES_WITHOUT_POOL': {
- let process = el?.$parent as El | undefined;
- while (process && !is(process, 'bpmn:Process')) process = process.$parent as El | undefined;
+ const process = enclosingProcess(el?.$parent as El | undefined);
return !!process && !doc.participantOf(process);
}
case 'W_IMPLICIT_SPLIT':
@@ -265,12 +263,12 @@ export class ModelCommandService {
}
/** An error thrown by op `i` names the op. */
- private atOp(opIndex: number, fn: () => unknown): void {
+ private atCommand(commandIndex: number, fn: () => unknown): void {
try {
fn();
} catch (err) {
if (err && typeof err === 'object' && 'details' in err) {
- (err as { details: Record }).details['op'] = opIndex;
+ (err as { details: Record }).details['op'] = commandIndex;
}
throw err;
}
diff --git a/src/domain/batch/types.ts b/src/domain/batch/types.ts
index e98d4011..b6ca8a16 100644
--- a/src/domain/batch/types.ts
+++ b/src/domain/batch/types.ts
@@ -1,4 +1,4 @@
-import type { IdConventions } from '../document/ids/IdConventionsReader.js';
+import type { IdConventions } from '../document/ids/IdConventions.js';
import type { DebugSink } from '../diagram/layout/LayoutDebug.js';
import type { DrawingOptions, LayoutStatus } from '../diagram/types.js';
import type { ChangeSet } from '../document/ChangeSet.js';
diff --git a/src/domain/commands/AliasTable.ts b/src/domain/commands/AliasTable.ts
index c448e462..d2efa24b 100644
--- a/src/domain/commands/AliasTable.ts
+++ b/src/domain/commands/AliasTable.ts
@@ -1,5 +1,5 @@
import type { Doc } from '../document/Doc.js';
-import { CliError } from '../document/CliError.js';
+import { BpmnError } from '../document/BpmnError.js';
import { usageError } from '../document/utils/errors.js';
import type { El } from '../document/utils/moddle.js';
import { isAlias, unknownAlias } from './utils/aliases.js';
@@ -20,7 +20,7 @@ export class AliasTable {
define(alias: string, el: El, op: number): void {
const prev = this.map.get(alias);
- if (prev) throw new CliError('E_DUPLICATE_ALIAS', `ops[${op}]: alias ${alias} is already defined by ops[${prev.op}]`, 'usage', { op, hint: 'Every alias names one element of the batch: pick another name.' });
+ if (prev) throw new BpmnError('E_DUPLICATE_ALIAS', `ops[${op}]: alias ${alias} is already defined by ops[${prev.op}]`, 'usage', { op, hint: 'Every alias names one element of the batch: pick another name.' });
if (!isAlias(alias)) throw usageError(`ops[${op}]: alias "${alias}" must look like $name ($ and a letter, then letters, digits, _ or -)`, { op });
this.map.set(alias, { el, op });
}
@@ -29,12 +29,12 @@ export class AliasTable {
for (const entry of this.map.values()) if (entry.el === from) entry.el = to;
}
- idOf(alias: string, index: number, op: EditCommand, key: string): string {
+ idOf(alias: string, index: number, command: EditCommand, key: string): string {
const entry = this.map.get(alias);
- if (!entry) throw unknownAlias(index, op, key, alias, new Map([...this.map].map(([aliasName, aliasEntry]) => [aliasName, aliasEntry.op])));
+ if (!entry) throw unknownAlias(index, command, key, alias, new Map([...this.map].map(([aliasName, aliasEntry]) => [aliasName, aliasEntry.op])));
const id = entry.el.get('id');
if (!id || this.doc.get(id) !== entry.el) {
- throw new CliError('E_NOT_FOUND', `ops[${index}] (${op.op}): "${key}": ${alias} named ${id ?? 'an element'}, which an op before this one removed`, 'model', { op: index, element: id, hint: 'Refer to an element that still exists.' });
+ throw new BpmnError('E_NOT_FOUND', `ops[${index}] (${command.op}): "${key}": ${alias} named ${id ?? 'an element'}, which an op before this one removed`, 'model', { op: index, element: id, hint: 'Refer to an element that still exists.' });
}
return id;
}
diff --git a/src/domain/commands/OpAliases.ts b/src/domain/commands/CommandAliases.ts
similarity index 87%
rename from src/domain/commands/OpAliases.ts
rename to src/domain/commands/CommandAliases.ts
index a007b7c0..6bc38cd1 100644
--- a/src/domain/commands/OpAliases.ts
+++ b/src/domain/commands/CommandAliases.ts
@@ -19,13 +19,13 @@ type Slot = { key: string; get: () => unknown; put: (slotValue: unknown) => void
* id goes, so generated ids need not be guessed. `$` cannot occur in a BPMN id; `${...}`
* expressions are not ids.
*/
-export class OpAliases {
- constructor(private readonly op: EditCommand) {}
+export class CommandAliases {
+ constructor(private readonly command: EditCommand) {}
/** The aliases the op defines (a split: also its branch nodes'). */
defined(): string[] {
- const raw = this.op as unknown as Raw;
- const definition = EditCommandDefinitions.find(this.op.op);
+ const raw = this.command as unknown as Raw;
+ const definition = EditCommandDefinitions.find(this.command.op);
const definingKeys = definition ? Object.keys(definition.fields).filter((key) => definition.fields[key]!.alias) : [];
const out = definingKeys.map((defKey) => raw[defKey]).filter((definedId): definedId is string => typeof definedId === 'string');
this.eachBranchNode(definition, raw, (node) => {
@@ -36,7 +36,7 @@ export class OpAliases {
referencedIds(): string[] {
const out: string[] = [];
- for (const slot of this.slots(this.op)) {
+ for (const slot of this.slots(this.command)) {
const slotValue = slot.get();
if (typeof slotValue === 'string') out.push(slotValue);
else if (Array.isArray(slotValue)) for (const entry of slotValue) if (typeof entry === 'string') out.push(entry);
@@ -47,7 +47,7 @@ export class OpAliases {
/** Every alias the op refers to, with the key it stands in (`ids[1]`, `values.default`). */
references(): Array<{ key: string; alias: string }> {
const out: Array<{ key: string; alias: string }> = [];
- for (const slot of this.slots(this.op)) {
+ for (const slot of this.slots(this.command)) {
const slotValue = slot.get();
if (isAlias(slotValue)) out.push({ key: slot.key, alias: slotValue });
else if (Array.isArray(slotValue)) slotValue.forEach((entry, index) => isAlias(entry) && out.push({ key: `${slot.key}[${index}]`, alias: entry }));
@@ -56,10 +56,10 @@ export class OpAliases {
}
resolve(table: AliasTable, index: number): EditCommand {
- const op = this.op;
- if (!this.references().length) return op;
- const copy = JSON.parse(JSON.stringify(op)) as EditCommand;
- const id = (alias: string, key: string): string => table.idOf(alias, index, op, key);
+ const command = this.command;
+ if (!this.references().length) return command;
+ const copy = JSON.parse(JSON.stringify(command)) as EditCommand;
+ const id = (alias: string, key: string): string => table.idOf(alias, index, command, key);
for (const slot of this.slots(copy)) {
const slotValue = slot.get();
if (isAlias(slotValue)) slot.put(id(slotValue, slot.key));
@@ -68,13 +68,13 @@ export class OpAliases {
return copy;
}
- private slots(op: EditCommand): Slot[] {
+ private slots(command: EditCommand): Slot[] {
const out: Slot[] = [];
const slot = (holder: Raw, name: string, key: string): void => {
out.push({ key, holder, name, get: () => holder[name], put: (slotValue) => (holder[name] = slotValue) });
};
- const raw = op as unknown as Raw;
- const definition = EditCommandDefinitions.find(op.op);
+ const raw = command as unknown as Raw;
+ const definition = EditCommandDefinitions.find(command.op);
for (const key of definition?.referenceKeys ?? []) slot(raw, key, key);
const map = (holder: Raw, name: string, path: string): void => {
const member = holder[name];
diff --git a/src/domain/commands/CommandFieldCheck.ts b/src/domain/commands/CommandFieldCheck.ts
index f326371a..b2b2a97f 100644
--- a/src/domain/commands/CommandFieldCheck.ts
+++ b/src/domain/commands/CommandFieldCheck.ts
@@ -1,6 +1,5 @@
-import type { CliError } from '../document/CliError.js';
-import { kindByName, gatewayKindList, normalizeTrigger, parseKind, type Family } from '../document/utils/kinds.js';
-import { KindError } from '../document/KindError.js';
+import { BpmnError } from '../document/BpmnError.js';
+import { kindByName, gatewayKindList, normalizeTrigger, parseKind, type Family } from '../document/Kinds.js';
import { commandUsageError, type CommandLocation } from './utils/command-location.js';
import { defaultWithConditionViolation, groupViolation, placementViolation } from './utils/op-rules.js';
import { FLOW_KEYS, PLACEMENT_KEYS, TRIGGER_KEYS } from './utils/op-fields.js';
@@ -11,7 +10,7 @@ export class CommandFieldCheck {
readonly fields: Record,
) {}
- fail(message: string, hint?: string, candidates?: string[]): CliError {
+ fail(message: string, hint?: string, candidates?: string[]): BpmnError {
return commandUsageError(this.location, message, hint, candidates);
}
@@ -63,7 +62,7 @@ export class CommandFieldCheck {
try {
family = parseKind(kind).def.family;
} catch (err) {
- if (err instanceof KindError) throw this.fail(err.message, 'Run `bpmn kinds` for the list of kinds and triggers.', err.candidates);
+ if (err instanceof BpmnError) throw this.fail(err.message, 'Run `bpmn kinds` for the list of kinds and triggers.', err.details.candidates ?? []);
throw err;
}
if (expectFamily && family !== expectFamily) {
diff --git a/src/domain/commands/CommandParser.ts b/src/domain/commands/CommandParser.ts
index 4b1b6d0f..7f794989 100644
--- a/src/domain/commands/CommandParser.ts
+++ b/src/domain/commands/CommandParser.ts
@@ -1,12 +1,12 @@
import { usageError } from '../document/utils/errors.js';
-import { CliError } from '../document/CliError.js';
+import { BpmnError } from '../document/BpmnError.js';
import { closestKey } from '../document/utils/fuzzy.js';
import { CommandFieldCheck } from './CommandFieldCheck.js';
import { EditCommandDefinitions } from './definitions/EditCommandDefinitions.js';
-import { OpAliases } from './OpAliases.js';
+import { CommandAliases } from './CommandAliases.js';
import { ALIAS, unknownAlias } from './utils/aliases.js';
import { commandUsageError, type CommandLocation } from './utils/command-location.js';
-import { OP_NAMES } from './utils/op-names.js';
+import { COMMAND_NAMES } from './utils/command-names.js';
import { parseSpaceAmount } from './utils/space-amount.js';
import type { FieldSpec, SplitNode } from './utils/field-specs.js';
import { BRANCH_FIELDS, NODE_FIELDS } from './utils/op-fields.js';
@@ -20,7 +20,7 @@ const ID_LIKE = new Set(['flowId', 'flow', 'elementId', 'element', 'nodeId', 'no
* library. Every problem is a usage error naming `ops[] ()`.
*/
export class CommandParser {
- private fail(ctx: CommandLocation, message: string, hint?: string, candidates?: string[]): CliError {
+ private fail(ctx: CommandLocation, message: string, hint?: string, candidates?: string[]): BpmnError {
return commandUsageError(ctx, message, hint, candidates);
}
@@ -193,7 +193,7 @@ export class CommandParser {
});
}
- private checkOp(ctx: CommandLocation, name: EditCommand['op'], raw: Record): EditCommand {
+ private checkCommand(ctx: CommandLocation, name: EditCommand['op'], raw: Record): EditCommand {
const definition = EditCommandDefinitions.of(name);
const out = this.checkFields(ctx, raw, definition.fields);
definition.check(new CommandFieldCheck(ctx, out));
@@ -204,12 +204,12 @@ export class CommandParser {
const base: CommandLocation = { index, path: `ops[${index}]` };
if (!this.isPlainObject(raw)) throw this.fail(base, `expected an op object, got ${this.describe(raw)}`, 'Every entry looks like {"op": "add", ...}.');
const name = raw['op'];
- if (name === undefined) throw this.fail(base, `missing "op"; one of: ${OP_NAMES.join(', ')}`);
- if (typeof name !== 'string' || !(OP_NAMES as string[]).includes(name)) {
- const alt = typeof name === 'string' ? closestKey(name, OP_NAMES) : undefined;
- throw this.fail(base, `unknown op ${JSON.stringify(name)}${alt ? ` (did you mean "${alt}"?)` : ''}; one of: ${OP_NAMES.join(', ')}`, undefined, alt ? [alt] : []);
+ if (name === undefined) throw this.fail(base, `missing "op"; one of: ${COMMAND_NAMES.join(', ')}`);
+ if (typeof name !== 'string' || !(COMMAND_NAMES as string[]).includes(name)) {
+ const alt = typeof name === 'string' ? closestKey(name, COMMAND_NAMES) : undefined;
+ throw this.fail(base, `unknown op ${JSON.stringify(name)}${alt ? ` (did you mean "${alt}"?)` : ''}; one of: ${COMMAND_NAMES.join(', ')}`, undefined, alt ? [alt] : []);
}
- return this.checkOp({ index, path: `ops[${index}] (${name})` }, name as EditCommand['op'], raw);
+ return this.checkCommand({ index, path: `ops[${index}] (${name})` }, name as EditCommand['op'], raw);
}
/** Validates the ops JSON (an array or `{ ops: [...] }`) and returns typed ops. */
@@ -237,28 +237,28 @@ export class CommandParser {
}
/** Before anything runs: every alias defined once and used only after the op defining it. */
- private checkAliases(ops: readonly EditCommand[]): void {
+ private checkAliases(commands: readonly EditCommand[]): void {
const defined = new Map();
const all = new Map();
- ops.forEach((op, opIndex) => {
- for (const alias of new OpAliases(op).defined()) if (!all.has(alias)) all.set(alias, opIndex);
+ commands.forEach((command, commandIndex) => {
+ for (const alias of new CommandAliases(command).defined()) if (!all.has(alias)) all.set(alias, commandIndex);
});
- ops.forEach((op, opIndex) => {
- const raw = op as unknown as Record;
- for (const [key, aliasKey] of Object.entries(EditCommandDefinitions.of(op.op).aliasKeyOfNewId)) {
+ commands.forEach((command, commandIndex) => {
+ const raw = command as unknown as Record;
+ for (const [key, aliasKey] of Object.entries(EditCommandDefinitions.of(command.op).aliasKeyOfNewId)) {
if (typeof raw[key] === 'string' && (raw[key] as string).startsWith('$')) {
- throw usageError(`ops[${opIndex}] (${op.op}): "${key}" is the id of the new element, not an alias; give the alias with "${aliasKey}": "${raw[key]}"`, { op: opIndex });
+ throw usageError(`ops[${commandIndex}] (${command.op}): "${key}" is the id of the new element, not an alias; give the alias with "${aliasKey}": "${raw[key]}"`, { op: commandIndex });
}
}
- for (const { key, alias } of new OpAliases(op).references()) {
- if (!defined.has(alias)) throw unknownAlias(opIndex, op, key, alias, defined, all.get(alias));
+ for (const { key, alias } of new CommandAliases(command).references()) {
+ if (!defined.has(alias)) throw unknownAlias(commandIndex, command, key, alias, defined, all.get(alias));
}
- for (const alias of new OpAliases(op).defined()) {
- if (!ALIAS.test(alias)) throw usageError(`ops[${opIndex}] (${op.op}): alias "${alias}" must look like $name ($ and a letter, then letters, digits, _ or -)`, { op: opIndex, hint: 'Example: "as": "$check", then "after": "$check".' });
+ for (const alias of new CommandAliases(command).defined()) {
+ if (!ALIAS.test(alias)) throw usageError(`ops[${commandIndex}] (${command.op}): alias "${alias}" must look like $name ($ and a letter, then letters, digits, _ or -)`, { op: commandIndex, hint: 'Example: "as": "$check", then "after": "$check".' });
if (defined.has(alias)) {
- throw new CliError('E_DUPLICATE_ALIAS', `ops[${opIndex}] (${op.op}): alias ${alias} is already defined by ops[${defined.get(alias)}]`, 'usage', { op: opIndex, hint: 'Every alias names one element of the batch: pick another name.' });
+ throw new BpmnError('E_DUPLICATE_ALIAS', `ops[${commandIndex}] (${command.op}): alias ${alias} is already defined by ops[${defined.get(alias)}]`, 'usage', { op: commandIndex, hint: 'Every alias names one element of the batch: pick another name.' });
}
- defined.set(alias, opIndex);
+ defined.set(alias, commandIndex);
}
});
}
diff --git a/src/domain/commands/definitions/AddElementDefinition.ts b/src/domain/commands/definitions/AddElementDefinition.ts
index c8396564..4332602b 100644
--- a/src/domain/commands/definitions/AddElementDefinition.ts
+++ b/src/domain/commands/definitions/AddElementDefinition.ts
@@ -1,4 +1,4 @@
-import { FLOW_NODE_FAMILIES, type Family } from '../../document/utils/kinds.js';
+import { FLOW_NODE_FAMILIES, type Family } from '../../document/Kinds.js';
import type { CommandFieldCheck } from '../CommandFieldCheck.js';
import { alias, bool, ref, type FieldsOf } from '../utils/field-specs.js';
import { FLOW_FIELDS, NODE_FIELDS, PLACEMENT_FIELDS, TRIGGER_FIELDS } from '../utils/op-fields.js';
diff --git a/src/domain/commands/definitions/AlignShapesDefinition.ts b/src/domain/commands/definitions/AlignShapesDefinition.ts
index 35f7506e..68aeab38 100644
--- a/src/domain/commands/definitions/AlignShapesDefinition.ts
+++ b/src/domain/commands/definitions/AlignShapesDefinition.ts
@@ -8,7 +8,7 @@ import { EditCommandDefinition } from './EditCommandDefinition.js';
export class AlignShapesDefinition extends EditCommandDefinition {
readonly name = 'align' as const;
readonly description = 'Diagram only: put shapes on one row or one column (= `bpmn align`).';
- override readonly diagramOnly = true;
+ override readonly diagramCommand = true;
readonly fields: FieldsOf = {
ids: list('Shapes to align (each moves on its own).', { minItems: 1 }),
...SELECTOR_FIELDS,
diff --git a/src/domain/commands/definitions/ColorElementsDefinition.ts b/src/domain/commands/definitions/ColorElementsDefinition.ts
index 910aa75e..86e7bcae 100644
--- a/src/domain/commands/definitions/ColorElementsDefinition.ts
+++ b/src/domain/commands/definitions/ColorElementsDefinition.ts
@@ -8,7 +8,7 @@ import { EditCommandDefinition } from './EditCommandDefinition.js';
export class ColorElementsDefinition extends EditCommandDefinition {
readonly name = 'color' as const;
readonly description = 'Diagram only: colour shapes and connections with a bpmn-js colour picker colour (= `bpmn color`).';
- override readonly diagramOnly = true;
+ override readonly diagramCommand = true;
readonly fields: FieldsOf = {
ids: list('Shapes and connections to colour.', { minItems: 1 }),
...SELECTOR_FIELDS,
diff --git a/src/domain/commands/definitions/CompactDiagramDefinition.ts b/src/domain/commands/definitions/CompactDiagramDefinition.ts
index 5cbc4d3f..e198d0a8 100644
--- a/src/domain/commands/definitions/CompactDiagramDefinition.ts
+++ b/src/domain/commands/definitions/CompactDiagramDefinition.ts
@@ -5,7 +5,7 @@ import { EditCommandDefinition } from './EditCommandDefinition.js';
export class CompactDiagramDefinition extends EditCommandDefinition {
readonly name = 'compact' as const;
readonly description = 'Diagram only: close empty rows and columns and shrink pools, lanes and expanded sub-processes to their content, keeping the order and relative positions; never adds a layout problem (= `bpmn compact`).';
- override readonly diagramOnly = true;
+ override readonly diagramCommand = true;
readonly fields: FieldsOf = {
ids: list('Only these frames (pools by participant id, lanes, expanded sub-processes) and what is inside them (default: the whole drawing).', { minItems: 1 }),
};
diff --git a/src/domain/commands/definitions/EditCommandDefinition.ts b/src/domain/commands/definitions/EditCommandDefinition.ts
index 6489cb17..5f8a9c02 100644
--- a/src/domain/commands/definitions/EditCommandDefinition.ts
+++ b/src/domain/commands/definitions/EditCommandDefinition.ts
@@ -7,7 +7,7 @@ export abstract class EditCommandDefinition & Record;
/** Changes only the drawing: runs after the layout of the model commands before it. */
- readonly diagramOnly: boolean = false;
+ readonly diagramCommand: boolean = false;
/** Keys that hold an element id (or a list of them): where an alias may stand. */
abstract readonly referenceKeys: readonly string[];
/** An alias in a new-id key is a mistake: the alias key it belongs in. */
diff --git a/src/domain/commands/definitions/InsertSpaceDefinition.ts b/src/domain/commands/definitions/InsertSpaceDefinition.ts
index 08c3406b..403d0afa 100644
--- a/src/domain/commands/definitions/InsertSpaceDefinition.ts
+++ b/src/domain/commands/definitions/InsertSpaceDefinition.ts
@@ -6,7 +6,7 @@ import { EditCommandDefinition } from './EditCommandDefinition.js';
export class InsertSpaceDefinition extends EditCommandDefinition {
readonly name = 'space' as const;
readonly description = 'Diagram only: insert space right of / below an element like the modeler\'s space tool (= `bpmn space`).';
- override readonly diagramOnly = true;
+ override readonly diagramCommand = true;
readonly fields: FieldsOf = {
after: ref('Insert horizontal space right of this element (everything starting right of it moves right, frames grow).'),
below: ref('Insert vertical space below this element (everything starting below it moves down, frames grow).'),
diff --git a/src/domain/commands/definitions/PlaceLabelDefinition.ts b/src/domain/commands/definitions/PlaceLabelDefinition.ts
index 0508091d..6cbab899 100644
--- a/src/domain/commands/definitions/PlaceLabelDefinition.ts
+++ b/src/domain/commands/definitions/PlaceLabelDefinition.ts
@@ -6,7 +6,7 @@ import { EditCommandDefinition } from './EditCommandDefinition.js';
export class PlaceLabelDefinition extends EditCommandDefinition {
readonly name = 'label' as const;
readonly description = 'Diagram only: put the external label of an event, gateway, data element or flow on one side (= `bpmn label`).';
- override readonly diagramOnly = true;
+ override readonly diagramCommand = true;
readonly fields: FieldsOf = {
id: ref('Event, gateway, data object / store or flow with a name (external label).', { required: true }),
side: str('Side of the element (flows: of the longest horizontal / vertical segment) the label goes to.', { required: true, values: LABEL_SIDE_VALUES }),
diff --git a/src/domain/commands/definitions/PlaceShapesDefinition.ts b/src/domain/commands/definitions/PlaceShapesDefinition.ts
index cbb2d96b..df818fd9 100644
--- a/src/domain/commands/definitions/PlaceShapesDefinition.ts
+++ b/src/domain/commands/definitions/PlaceShapesDefinition.ts
@@ -14,7 +14,7 @@ const PLACE_GROUPS = [
export class PlaceShapesDefinition extends EditCommandDefinition {
readonly name = 'place' as const;
readonly description = 'Diagram only: move shapes (rigid group) to the row and/or column of another element (= `bpmn place`).';
- override readonly diagramOnly = true;
+ override readonly diagramCommand = true;
readonly fields: FieldsOf = {
ids: list('Shapes moved as one rigid group; the first id is the reference that lands on the target row / column. Boundary events, labels and the content of an expanded sub-process follow.', { minItems: 1 }),
...SELECTOR_FIELDS,
diff --git a/src/domain/commands/definitions/RerouteFlowDefinition.ts b/src/domain/commands/definitions/RerouteFlowDefinition.ts
index 2f2d45d4..14564aed 100644
--- a/src/domain/commands/definitions/RerouteFlowDefinition.ts
+++ b/src/domain/commands/definitions/RerouteFlowDefinition.ts
@@ -6,7 +6,7 @@ import { EditCommandDefinition } from './EditCommandDefinition.js';
export class RerouteFlowDefinition extends EditCommandDefinition {
readonly name = 'route' as const;
readonly description = 'Diagram only: route one flow again, optionally forcing the exit / entry side (= `bpmn route`).';
- override readonly diagramOnly = true;
+ override readonly diagramCommand = true;
readonly fields: FieldsOf = {
id: ref('Sequence flow or message flow to route again.', { required: true }),
exit: str('Side the flow leaves its source by (default: the conventions).', { values: SIDE_VALUES }),
diff --git a/src/domain/commands/definitions/TidyShapesDefinition.ts b/src/domain/commands/definitions/TidyShapesDefinition.ts
index e40df2a1..14400a91 100644
--- a/src/domain/commands/definitions/TidyShapesDefinition.ts
+++ b/src/domain/commands/definitions/TidyShapesDefinition.ts
@@ -7,7 +7,7 @@ import { EditCommandDefinition } from './EditCommandDefinition.js';
export class TidyShapesDefinition extends EditCommandDefinition {
readonly name = 'tidy' as const;
readonly description = 'Diagram only: remove overlaps and gaps < 20 px with minimal moves, keeping the order (= `bpmn tidy`, `bpmn layout --tidy`).';
- override readonly diagramOnly = true;
+ override readonly diagramCommand = true;
readonly fields: FieldsOf = {
ids: list('Only these shapes (default: every shape of every diagram).', { minItems: 1 }),
...SELECTOR_FIELDS,
diff --git a/src/domain/commands/utils/aliases.ts b/src/domain/commands/utils/aliases.ts
index 0a46980f..b922e065 100644
--- a/src/domain/commands/utils/aliases.ts
+++ b/src/domain/commands/utils/aliases.ts
@@ -1,4 +1,4 @@
-import { CliError } from '../../document/CliError.js';
+import { BpmnError } from '../../document/BpmnError.js';
import { editDistance, typoTolerance } from '../../document/utils/fuzzy.js';
import { EditCommandDefinitions } from '../definitions/EditCommandDefinitions.js';
import type { EditCommand } from '../types.js';
@@ -15,15 +15,15 @@ function opsDefining(key: string): string {
}
function listing(defined: ReadonlyMap): string {
- return defined.size ? `defined so far: ${[...defined].map(([aliasName, opIndex]) => `${aliasName} (ops[${opIndex}])`).join(', ')}` : 'no op before it defines one';
+ return defined.size ? `defined so far: ${[...defined].map(([aliasName, commandIndex]) => `${aliasName} (ops[${commandIndex}])`).join(', ')}` : 'no op before it defines one';
}
-export function unknownAlias(index: number, op: EditCommand, key: string, alias: string, defined: ReadonlyMap, later?: number): CliError {
+export function unknownAlias(index: number, command: EditCommand, key: string, alias: string, defined: ReadonlyMap, later?: number): BpmnError {
const where = later === undefined ? '' : later === index ? ' yet (this op defines it: only the ops after it can use it)' : later > index ? ` before this op (ops[${later}] defines it)` : '';
const distance = (candidate: string): number => editDistance(candidate.toLowerCase(), alias.toLowerCase());
const candidates = [...defined.keys()].sort((left, right) => distance(left) - distance(right));
const close = candidates[0] !== undefined && later === undefined && distance(candidates[0]) <= Math.max(1, typoTolerance(alias.length)) ? ` (did you mean ${candidates[0]}?)` : '';
- return new CliError('E_UNKNOWN_ALIAS', `ops[${index}] (${op.op}): "${key}": alias ${alias} is not defined${where}${close}; ${listing(defined)}`, 'usage', {
+ return new BpmnError('E_UNKNOWN_ALIAS', `ops[${index}] (${command.op}): "${key}": alias ${alias} is not defined${where}${close}; ${listing(defined)}`, 'usage', {
op: index,
candidates,
hint: `An op defines an alias with "as": "$name" (${opsDefining('as')} and the nodes of a split branch), "flowAs" (${opsDefining('flowAs')}: the flow into the new node), "outAs" (${opsDefining('outAs')}: the flow it creates out of the new node), "joinAs" (${opsDefining('joinAs')}: the join gateway) or "refAs" (${opsDefining('refAs')}: the message / error / signal / escalation the element references); only the ops after it can use it.`,
diff --git a/src/domain/commands/utils/command-location.ts b/src/domain/commands/utils/command-location.ts
index c1d6e5cb..a31cd5ac 100644
--- a/src/domain/commands/utils/command-location.ts
+++ b/src/domain/commands/utils/command-location.ts
@@ -1,5 +1,5 @@
import { usageError } from '../../document/utils/errors.js';
-import type { CliError } from '../../document/CliError.js';
+import type { BpmnError } from '../../document/BpmnError.js';
export interface CommandLocation {
index: number;
@@ -7,7 +7,7 @@ export interface CommandLocation {
path: string;
}
-export function commandUsageError(location: CommandLocation, message: string, hint?: string, candidates?: string[]): CliError {
+export function commandUsageError(location: CommandLocation, message: string, hint?: string, candidates?: string[]): BpmnError {
return usageError(`${location.path}: ${message}`, {
op: location.index,
...(hint ? { hint } : {}),
diff --git a/src/domain/commands/utils/command-names.ts b/src/domain/commands/utils/command-names.ts
new file mode 100644
index 00000000..5421b16f
--- /dev/null
+++ b/src/domain/commands/utils/command-names.ts
@@ -0,0 +1,11 @@
+import { EditCommandDefinitions } from '../definitions/EditCommandDefinitions.js';
+import type { DiagramCommand, EditCommand } from '../types.js';
+
+export const COMMAND_NAMES = EditCommandDefinitions.all().map((definition) => definition.name) as Array;
+
+/** The diagram-only ops (they run in batch order, after the layout of the model ops before them). */
+export const DIAGRAM_COMMAND_NAMES = EditCommandDefinitions.all().filter((definition) => definition.diagramCommand).map((definition) => definition.name) as Array;
+
+export function isDiagramCommand(command: EditCommand): command is DiagramCommand {
+ return EditCommandDefinitions.find(command.op)?.diagramCommand ?? false;
+}
diff --git a/src/domain/commands/utils/field-specs.ts b/src/domain/commands/utils/field-specs.ts
index 48ec5297..54d2c3e2 100644
--- a/src/domain/commands/utils/field-specs.ts
+++ b/src/domain/commands/utils/field-specs.ts
@@ -14,7 +14,7 @@ export interface FieldSpec {
alias?: boolean;
}
-export type FieldsOf = { [TKey in Exclude]-?: FieldSpec };
+export type FieldsOf = { [TKey in Exclude]-?: FieldSpec };
export type SplitNode = SplitBranch['nodes'][number];
diff --git a/src/domain/commands/utils/op-names.ts b/src/domain/commands/utils/op-names.ts
deleted file mode 100644
index be0e77be..00000000
--- a/src/domain/commands/utils/op-names.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { EditCommandDefinitions } from '../definitions/EditCommandDefinitions.js';
-import type { DiagramCommand, EditCommand } from '../types.js';
-
-export const OP_NAMES = EditCommandDefinitions.all().map((definition) => definition.name) as Array;
-
-/** The diagram-only ops (they run in batch order, after the layout of the model ops before them). */
-export const FORMAT_OP_NAMES = EditCommandDefinitions.all().filter((definition) => definition.diagramOnly).map((definition) => definition.name) as Array;
-
-export function isDiagramCommand(op: EditCommand): op is DiagramCommand {
- return EditCommandDefinitions.find(op.op)?.diagramOnly ?? false;
-}
diff --git a/src/domain/commands/utils/ops-json.ts b/src/domain/commands/utils/ops-json.ts
index bce26957..892d28df 100644
--- a/src/domain/commands/utils/ops-json.ts
+++ b/src/domain/commands/utils/ops-json.ts
@@ -1,9 +1,9 @@
-import { CliError } from '../../document/CliError.js';
+import { BpmnError } from '../../document/BpmnError.js';
export function parseOpsJson(text: string): unknown {
try {
return JSON.parse(text);
} catch (err) {
- throw new CliError('E_USAGE', `Ops are not valid JSON: ${(err as Error).message}`, 'usage');
+ throw new BpmnError('E_USAGE', `Ops are not valid JSON: ${(err as Error).message}`, 'usage');
}
}
diff --git a/src/domain/diagram/BatchDrawing.ts b/src/domain/diagram/BatchDrawing.ts
index 00721c4e..97bdc0f4 100644
--- a/src/domain/diagram/BatchDrawing.ts
+++ b/src/domain/diagram/BatchDrawing.ts
@@ -1,26 +1,27 @@
import { Doc } from '../document/Doc.js';
-import { CliError } from '../document/CliError.js';
-import { FullLayout, type LayoutEngine } from './layout/FullLayout.js';
+import { BpmnError } from '../document/BpmnError.js';
+import { FullLayout } from './layout/FullLayout.js';
import { SUB_PROCESS_TYPES } from './utils/expansion.js';
+import { SIZES } from './utils/sizes.js';
import type { GivenId } from './di/DrawnElementIds.js';
import { DiagramGeometry } from './layout/DiagramGeometry.js';
import { IncrementalLayout, type IncrementalReport } from './layout/incremental/IncrementalLayout.js';
import { Snapshot } from './layout/incremental/Snapshot.js';
import { LayoutAnalysis } from './di/LayoutAnalysis.js';
import { metricsDelta, type LayoutMetrics } from './utils/problems.js';
-import { DiagramCommandService, type FormatEntry, type FormatResult } from './DiagramCommandService.js';
+import { DiagramCommands, type DiagramCommandEntry } from './commands/DiagramCommands.js';
import { Stickies, type ResolvedAnchor, type StickyAnchor } from './di/Stickies.js';
import { applyColors, colorsOf } from './utils/colors.js';
import { DiIds } from './di/DiIds.js';
import { Groups } from './di/Groups.js';
-import { addTo, is, layoutRoot, many, parseXml, serialize, type El } from '../document/utils/moddle.js';
+import { addTo, is, layoutRoot, many, parseXml, type El } from '../document/utils/moddle.js';
import { ModelError } from '../document/ModelError.js';
-import type { EditContext } from '../document/EditContext.js';
+import type { EditSession } from '../document/EditSession.js';
import type { ChangeSet } from '../document/ChangeSet.js';
-import { isDiagramCommand } from '../commands/utils/op-names.js';
+import { isDiagramCommand } from '../commands/utils/command-names.js';
import type { EditCommand } from '../commands/types.js';
import { firstLine } from '../document/utils/text.js';
-import { toCliError } from '../document/utils/cli-error.js';
+import { toBpmnError } from '../document/utils/bpmn-error.js';
import type { DrawingOptions, LayoutMode, LayoutStatus } from './types.js';
interface Before {
@@ -45,8 +46,6 @@ export interface DrawingSegment {
export type RemoveElements = (doc: Doc, ids: string[]) => void;
interface Ran {
- /** the drawing bpmn-auto-layout wrote; absent when the document holds it */
- engineText?: string;
status: LayoutStatus;
after: LayoutMetrics;
givenIds?: GivenId[];
@@ -65,7 +64,7 @@ export class BatchDrawing {
private readonly doc: Doc,
private readonly opts: DrawingOptions,
private requested: LayoutMode | 'skip',
- private readonly formatOnly: boolean,
+ private readonly diagramCommandsOnly: boolean,
private readonly before: Before,
private readonly removeElements: RemoveElements,
) {}
@@ -74,15 +73,15 @@ export class BatchDrawing {
* `asRead`: the document as read, without the entries a caller's validateDoc added in memory;
* `removeElements` takes the undrawn nodes out of a copy for the engine-owned check.
*/
- static async capture(doc: Doc, ops: EditCommand[], opts: DrawingOptions, asRead: string | undefined, removeElements: RemoveElements): Promise {
+ static async capture(doc: Doc, commands: EditCommand[], opts: DrawingOptions, asRead: string | undefined, removeElements: RemoveElements): Promise {
const requested = opts.layout === false ? 'skip' : opts.layout === undefined || opts.layout === true ? 'auto' : opts.layout;
- const formatOnly = ops.length > 0 && ops.every(isDiagramCommand);
+ const diagramCommandsOnly = commands.length > 0 && commands.every(isDiagramCommand);
const snapshot = Snapshot.take(doc.definitions);
const hasDiagram = many(doc.definitions, 'diagrams').length > 0 && snapshot.byDi.size > 0;
const before: Before = { snapshot, stickies: new Stickies(doc.definitions).anchors() };
if (hasDiagram) before.metrics = LayoutAnalysis.of(doc.definitions);
- if (requested === 'auto' && !formatOnly && snapshot.flowNodeShapes > 0) before.xml = asRead ?? (await doc.toXml());
- return new BatchDrawing(doc, opts, requested, formatOnly, before, removeElements);
+ if (requested === 'auto' && !diagramCommandsOnly && snapshot.flowNodeShapes > 0) before.xml = asRead ?? (await doc.toXml());
+ return new BatchDrawing(doc, opts, requested, diagramCommandsOnly, before, removeElements);
}
/**
@@ -90,22 +89,22 @@ export class BatchDrawing {
* itself; the next layout keeps that drawing like a hand-made one. Between two
* full redraws the clean engine draws: only its drawing is the document's own.
*/
- async drawBetween(edit: EditContext, ran: EditCommand[], changes: ChangeSet, segment: DrawingSegment): Promise {
+ async drawBetween(edit: EditSession, ran: EditCommand[], changes: ChangeSet, segment: DrawingSegment): Promise {
const doc = this.doc;
let layout: LayoutStatus = { status: this.requested === 'skip' ? 'skipped' : 'ok', warnings: [], expanded: [] };
const keepsDrawing = this.requested === 'skip' || (!segment.model && this.requested !== 'full' && this.before.snapshot.flowNodeShapes > 0);
if (!keepsDrawing && this.requested !== 'skip') {
let givenIds: GivenId[] | undefined;
try {
- ({ layout, givenIds } = await this.runLayout(edit, this.requested, BatchDrawing.relocatedIds(ran.slice(segment.start, segment.end), changes), segment.created, 'clean'));
+ ({ layout, givenIds } = await this.runLayout(edit, this.requested, BatchDrawing.relocatedIds(ran.slice(segment.start, segment.end), changes), segment.created));
} catch (err) {
- throw toCliError(err);
+ throw toBpmnError(err);
}
BatchDrawing.noteGivenIds(changes, givenIds);
}
- const entries = BatchDrawing.formatEntries(ran).filter((entry) => entry.index >= segment.start && entry.index < segment.end);
- const format = entries.length ? new DiagramCommandService(doc).run(entries) : [];
- if (format.length) layout.format = format;
+ const entries = BatchDrawing.diagramCommandEntries(ran).filter((entry) => entry.index >= segment.start && entry.index < segment.end);
+ const diagramResults = entries.length ? new DiagramCommands(doc).run(entries) : [];
+ if (diagramResults.length) layout.format = diagramResults;
this.drawn.push(layout);
this.before.snapshot = Snapshot.take(doc.definitions);
this.before.xml = undefined;
@@ -114,16 +113,14 @@ export class BatchDrawing {
/**
* `ran` holds the ops with their batch aliases resolved; `segment` the commands since
- * drawBetween last ran (the whole batch when it did not). The drawing is the document's
- * own, except after a bpmn-auto-layout redraw: then it is the text that engine wrote.
+ * drawBetween last ran (the whole batch when it did not). The drawing is the document's own.
* Without a layout, dangling DI and edges showing a flow's old ends are pruned, so the next write
* draws such a flow like a new one. A format op on the whole batch draws the file itself, so its
* refusal may name a command to run on the file.
*/
- async apply(edit: EditContext, ran: EditCommand[], changes: ChangeSet, segment?: DrawingSegment): Promise<{ xml: string; layout: LayoutStatus }> {
+ async apply(edit: EditSession, ran: EditCommand[], changes: ChangeSet, segment?: DrawingSegment): Promise<{ xml: string; layout: LayoutStatus }> {
const doc = this.doc;
- const current = segment ?? { start: 0, end: ran.length, model: !this.formatOnly, created: changes.created.map((created) => created.id) };
- let engineText: string | undefined;
+ const current = segment ?? { start: 0, end: ran.length, model: !this.diagramCommandsOnly, created: changes.created.map((created) => created.id) };
let layout: LayoutStatus;
let fileDrawing = false;
if (this.requested === 'skip') {
@@ -138,35 +135,22 @@ export class BatchDrawing {
} else {
let givenIds: GivenId[] | undefined;
try {
- ({ engineText, layout, givenIds } = await this.runLayout(edit, this.requested, BatchDrawing.relocatedIds(ran.slice(current.start, current.end), changes), current.created));
+ ({ layout, givenIds } = await this.runLayout(edit, this.requested, BatchDrawing.relocatedIds(ran.slice(current.start, current.end), changes), current.created));
} catch (err) {
- throw toCliError(err);
+ throw toBpmnError(err);
}
BatchDrawing.noteGivenIds(changes, givenIds);
}
- const entries = BatchDrawing.formatEntries(ran).filter((entry) => entry.index >= current.start && entry.index < current.end);
- if (engineText === undefined) {
- const format = entries.length ? new DiagramCommandService(doc).run(entries, fileDrawing) : [];
- if (format.length) {
- layout.format = format;
- layout.metrics = metricsDelta(this.before.metrics, LayoutAnalysis.of(doc.definitions));
- }
- const moved = new Stickies(doc.definitions).follow(this.resolveAnchors());
- if (moved.length) layout.stickies = moved;
- return { xml: await doc.toXml(), layout: this.withDrawn(layout) };
- }
- let xml = engineText;
- const formatted = entries.length ? await BatchDrawing.runFormat(xml, doc.file, entries) : undefined;
- if (formatted?.results.length) {
- xml = formatted.xml;
- layout.format = formatted.results;
- layout.metrics = metricsDelta(this.before.metrics, formatted.after);
+ const entries = BatchDrawing.diagramCommandEntries(ran).filter((entry) => entry.index >= current.start && entry.index < current.end);
+ const diagramResults = entries.length ? new DiagramCommands(doc).run(entries, fileDrawing) : [];
+ if (diagramResults.length) {
+ layout.format = diagramResults;
+ layout.metrics = metricsDelta(this.before.metrics, LayoutAnalysis.of(doc.definitions));
}
- const followed = await BatchDrawing.moveStickies(xml, this.resolveAnchors());
- xml = followed.xml;
- if (followed.moved.length) layout.stickies = followed.moved;
- return { xml, layout: this.withDrawn(layout) };
+ const moved = new Stickies(doc.definitions).follow(this.resolveAnchors());
+ if (moved.length) layout.stickies = moved;
+ return { xml: await doc.toXml(), layout: this.withDrawn(layout) };
}
private static noteGivenIds(changes: ChangeSet, givenIds: GivenId[] | undefined): void {
@@ -273,7 +257,7 @@ export class BatchDrawing {
id: (newDi ??= new DiIds(doc.definitions)).shape(id),
bpmnElement: sub,
isExpanded: false,
- bounds: doc.moddle.create('dc:Bounds', { x: 0, y: 0, width: 100, height: 80 }),
+ bounds: doc.moddle.create('dc:Bounds', { x: 0, y: 0, ...SIZES.task }),
});
addTo(rootPlane, 'planeElement', shape);
}
@@ -281,11 +265,11 @@ export class BatchDrawing {
}
/** A node a move left where it was (`move B --after A` with A -> B) keeps its place. */
- private static relocatedIds(ops: EditCommand[], changes: ChangeSet): string[] {
+ private static relocatedIds(commands: EditCommand[], changes: ChangeSet): string[] {
const moved = new Set(changes.relocated);
const out: string[] = [];
- for (const op of ops) {
- if (op.op === 'move' && (op.after || op.before || op.flow || op.in)) out.push(...op.ids.filter((id) => moved.has(id)));
+ for (const command of commands) {
+ if (command.op === 'move' && (command.after || command.before || command.flow || command.in)) out.push(...command.ids.filter((id) => moved.has(id)));
}
return out;
}
@@ -301,34 +285,21 @@ export class BatchDrawing {
return out;
}
- private static async moveStickies(xml: string, anchors: ResolvedAnchor[]): Promise<{ xml: string; moved: Array<{ sticky: string; node: string }> }> {
- if (!anchors.length) return { xml, moved: [] };
- const model = await parseXml(xml);
- const moved = new Stickies(model.definitions).follow(anchors);
- return { xml: moved.length ? await serialize(model) : xml, moved };
- }
-
/** Diagram commands and orders run on the laid-out drawing: the diagram commands decide which orders redraw bands. */
- private static formatEntries(ops: EditCommand[]): FormatEntry[] {
- const out: FormatEntry[] = [];
- ops.forEach((op, index) => {
- if (isDiagramCommand(op) || op.op === 'order') out.push({ op, index });
+ private static diagramCommandEntries(commands: EditCommand[]): DiagramCommandEntry[] {
+ const out: DiagramCommandEntry[] = [];
+ commands.forEach((command, index) => {
+ if (isDiagramCommand(command) || command.op === 'order') out.push({ op: command, index });
});
return out;
}
- private static async runFormat(xml: string, file: string | undefined, entries: FormatEntry[]): Promise<{ xml: string; results: FormatResult[]; after: LayoutMetrics }> {
- const laid = await Doc.fromXml(xml, file);
- const results = new DiagramCommandService(laid).run(entries);
- return { xml: await laid.toXml(), results, after: LayoutAnalysis.of(laid.definitions) };
- }
-
/**
* Whether re-running the engine reproduces the drawing. Undrawn flow nodes are removed
* first: a stale but engine-made drawing is still engine-owned.
* The bridged flows keep their ids, so the drawing is compared edge by edge.
*/
- private async isEngineOwned(xml: string, engine: LayoutEngine | undefined, undrawn: readonly string[]): Promise {
+ private async isEngineOwned(xml: string, undrawn: readonly string[]): Promise {
const model = await parseXml(xml);
const mine = DiagramGeometry.of(model.definitions);
let reference = xml;
@@ -344,11 +315,7 @@ export class BatchDrawing {
}
const clean = await parseXml(reference);
await new FullLayout(clean, {}).run();
- if (mine.drawnBy(clean.definitions)) return true;
- if (engine !== 'auto') return false;
- const auto = await parseXml(reference);
- const result = await new FullLayout(auto, { engine: 'auto' }).run();
- return mine.drawnBy((await parseXml(result.xml)).definitions);
+ return mine.drawnBy(clean.definitions);
}
private async decideMode(requested: LayoutMode): Promise<{ mode: 'full' | 'incremental'; reason: string }> {
@@ -358,28 +325,20 @@ export class BatchDrawing {
if (requested === 'incremental') return { mode: 'incremental', reason: 'incremental layout requested' };
const undrawn = before.snapshot.undrawn;
const missing = undrawn.length ? `${undrawn.length} flow node(s) had no shape (e.g. added with --no-layout)` : '';
- if (before.xml && (await this.isEngineOwned(before.xml, this.opts.engine, undrawn))) return { mode: 'full', reason: missing ? `engine-owned diagram (${missing}): redrawn` : 'engine-owned diagram: redrawn' };
+ if (before.xml && (await this.isEngineOwned(before.xml, undrawn))) return { mode: 'full', reason: missing ? `engine-owned diagram (${missing}): redrawn` : 'engine-owned diagram: redrawn' };
if (missing) return { mode: 'incremental', reason: `diagram kept: ${missing}; they are placed locally like new elements (\`bpmn layout\` redraws everything)` };
return { mode: 'incremental', reason: 'hand-made diagram: kept, changes placed locally' };
}
/** Colours survive the redraw by element id. */
- private async fullLayout(edit: EditContext, engine = this.opts.engine): Promise {
+ private async fullLayout(edit: EditSession): Promise {
const doc = this.doc;
const colors = colorsOf(doc.definitions);
const expansion = doc.layoutRequests.expansion(this.opts);
- const result = await new FullLayout(doc.model, { ...expansion, engine, allocateId: (req) => edit.allocator.allocate(req).id }).run();
+ const result = await new FullLayout(doc.model, { ...expansion, allocateId: (req) => edit.allocator.allocate(req).id }).run();
if (result.givenIds?.length) doc.invalidate();
- let engineText: string | undefined;
- let defs = doc.definitions;
- if ((engine ?? 'clean') !== 'clean') {
- const laidOut = await parseXml(result.xml);
- defs = laidOut.definitions;
- engineText = applyColors(laidOut.moddle, defs, colors) ? await serialize(laidOut) : result.xml;
- } else {
- applyColors(doc.moddle, defs, colors);
- }
- return { ...(engineText !== undefined ? { engineText } : {}), status: { status: 'ok', mode: 'full', warnings: result.warnings, expanded: result.expanded, ...(result.notes?.length ? { notes: result.notes } : {}) }, after: LayoutAnalysis.of(defs), ...(result.givenIds?.length ? { givenIds: result.givenIds } : {}) };
+ applyColors(doc.moddle, doc.definitions, colors);
+ return { status: { status: 'ok', mode: 'full', warnings: result.warnings, expanded: result.expanded, ...(result.notes?.length ? { notes: result.notes } : {}) }, after: LayoutAnalysis.of(doc.definitions), ...(result.givenIds?.length ? { givenIds: result.givenIds } : {}) };
}
private async incrementalLayout(relocated: string[], created: readonly string[]): Promise {
@@ -405,26 +364,26 @@ export class BatchDrawing {
}
/** In auto mode a failing incremental layout falls back to a full redraw; requested, it fails. */
- private async runLayout(edit: EditContext, requested: LayoutMode, relocated: string[], created: readonly string[], engine = this.opts.engine): Promise<{ engineText?: string; layout: LayoutStatus; givenIds?: GivenId[] }> {
+ private async runLayout(edit: EditSession, requested: LayoutMode, relocated: string[], created: readonly string[]): Promise<{ layout: LayoutStatus; givenIds?: GivenId[] }> {
const decision = await this.decideMode(requested);
let ran: Ran;
- if (decision.mode === 'full') ran = await this.fullLayout(edit, engine);
+ if (decision.mode === 'full') ran = await this.fullLayout(edit);
else {
try {
ran = await this.incrementalLayout(relocated, created);
} catch (err) {
- if (err instanceof CliError) throw err;
+ if (err instanceof BpmnError) throw err;
if (requested === 'incremental') {
throw new ModelError(`Incremental layout failed: ${firstLine(err)}`, 'LAYOUT_INCREMENTAL', {
hint: 'Retry with --layout full (redraws the whole diagram) or --no-layout (writes without updating the diagram), and report the command and file.',
});
}
- ran = await this.fullLayout(edit, engine);
+ ran = await this.fullLayout(edit);
decision.mode = 'full';
decision.reason = `incremental layout failed (${firstLine(err)}): redrawn`;
ran.status.warnings.push({ code: 'INCREMENTAL_FAILED', elementId: '', message: `The incremental layout failed and the diagram was redrawn: ${firstLine(err)}`, relatedElementIds: [] });
}
}
- return { ...(ran.engineText !== undefined ? { engineText: ran.engineText } : {}), layout: { ...ran.status, mode: decision.mode, reason: decision.reason, metrics: metricsDelta(this.before.metrics, ran.after) }, ...(ran.givenIds ? { givenIds: ran.givenIds } : {}) };
+ return { layout: { ...ran.status, mode: decision.mode, reason: decision.reason, metrics: metricsDelta(this.before.metrics, ran.after) }, ...(ran.givenIds ? { givenIds: ran.givenIds } : {}) };
}
}
diff --git a/src/domain/diagram/DiagramCommandService.ts b/src/domain/diagram/DiagramCommandService.ts
deleted file mode 100644
index 72ecaa63..00000000
--- a/src/domain/diagram/DiagramCommandService.ts
+++ /dev/null
@@ -1,139 +0,0 @@
-import type { Doc } from '../document/Doc.js';
-import { CliError } from '../document/CliError.js';
-import type { DiagramCommand, ReorderElements } from '../commands/types.js';
-import { ReorderTarget } from '../commands/ReorderTarget.js';
-import { cx } from './utils/geom.js';
-import type { DShape } from './di/types.js';
-import { DrawnDiagram } from './DrawnDiagram.js';
-import { unique } from './utils/frames.js';
-import { ShapePlacementService } from './format/ShapePlacementService.js';
-import { SpaceInsertionService } from './format/SpaceInsertionService.js';
-import { RoomHint } from './format/RoomHint.js';
-import { LaneBandService } from './format/LaneBandService.js';
-import { ElementSelector } from './format/ElementSelector.js';
-
-export interface FormatResult {
- op: DiagramCommand['op'] | 'order';
- index: number;
- moved: string[];
- rerouted: string[];
- /** waypoints changed without a new route (stretched by moves, the space tool, compact) */
- reshaped?: string[];
- colored?: string[];
- labels?: string[];
- notes?: string[];
-}
-
-export interface FormatEntry {
- op: DiagramCommand | ReorderElements;
- index: number;
-}
-
-/**
- * Runs diagram commands (place, align, color, label, route, space, tidy, compact, lane order)
- * through their domain services on the drawing, in batch order; writes the planes back once at the end.
- * A failing op throws with its batch index in `details.op`, so the batch writes nothing.
- */
-export class DiagramCommandService {
- readonly diagram: DrawnDiagram;
-
- constructor(readonly doc: Doc) {
- this.diagram = new DrawnDiagram(doc);
- }
-
- /**
- * `asFile`: `doc` holds the file's own drawing, so the first refusal may name a command.
- * An `order` of a node's outgoing flows leaves the drawing alone: only bands are redrawn.
- */
- run(entries: readonly FormatEntry[], asFile = false): FormatResult[] {
- const drawn = entries.filter((entry) => entry.op.op !== 'order' || new ReorderTarget(this.doc, entry.op).ordersBands());
- if (entries.length && !drawn.length) return [];
- const results: FormatResult[] = [];
- for (const [position, entry] of drawn.entries()) {
- try {
- results.push(this.runOne(entry, asFile && position === 0 && entry.index === 0));
- } catch (err) {
- if (err instanceof CliError) err.details['op'] = entry.index;
- throw err;
- }
- }
- this.diagram.write();
- return results;
- }
-
- /**
- * Explicit ids first, then what --branch, --path and --kind name. A column align picked by
- * --kind alone aligns on the rightmost shape, so nothing moves left. `picked`: ids only a
- * selector named; align skips those that would leave their frame.
- */
- private resolveSelection(op: TOp): { op: TOp; picked: Set } {
- const none = { op, picked: new Set() };
- if (op.op !== 'place' && op.op !== 'align' && op.op !== 'color' && op.op !== 'tidy') return none;
- if (!op.path && !op.kind && !op.branch) return none;
- if (!this.diagram.planes.length) throw this.diagram.noDiagram();
- const shapeOf = (id: string): DShape | undefined => this.diagram.planes.map((plane) => plane.shapes.get(id)).find((found) => !!found);
- const ids = new ElementSelector(this.doc).select(op, {
- shapesOnly: op.op !== 'color',
- drawn: (id) => this.diagram.planes.some((plane) => plane.shapes.has(id) || plane.edges.has(id)),
- centreX: (id) => {
- const shape = shapeOf(id);
- return shape ? cx(shape.bounds) : undefined;
- },
- });
- const explicit = new Set(op.ids ?? []);
- const picked = new Set(ids.filter((id) => !explicit.has(id)));
- if (op.op === 'align' && !op.to && !op.ids?.length && !op.path && !op.branch && op.axis === 'column') {
- const right = [...ids].sort((one, other) => cx(shapeOf(other)?.bounds ?? { x: 0, y: 0, width: 0, height: 0 }) - cx(shapeOf(one)?.bounds ?? { x: 0, y: 0, width: 0, height: 0 }))[0];
- return { op: { ...op, ids, ...(right ? { to: right } : {}) }, picked };
- }
- return { op: { ...op, ids }, picked };
- }
-
- private runOne(entry: FormatEntry, asFile: boolean): FormatResult {
- const index = entry.index;
- const resolved = entry.op.op === 'order' ? { op: entry.op, picked: new Set() } : this.resolveSelection(entry.op);
- const op = resolved.op;
- const snap = this.diagram.snapshot();
- const notes: string[] = [];
- let rerouted: string[] = [];
- const extra: Partial = {};
- const diagram = this.diagram;
- switch (op.op) {
- case 'place': {
- const place = () => new ShapePlacementService(diagram, op, snap, notes).run();
- rerouted = op.side ? place() : new RoomHint(diagram, op, asFile).run(place);
- break;
- }
- case 'align':
- rerouted = new RoomHint(diagram, op, asFile).run(() => diagram.alignShapes(op, snap, notes, resolved.picked));
- break;
- case 'color':
- extra.colored = diagram.colorElements(op);
- break;
- case 'label': {
- const labelled = diagram.placeLabel(op, snap, notes);
- extra.labels = labelled.labels;
- rerouted = labelled.rerouted;
- break;
- }
- case 'route':
- rerouted = diagram.rerouteFlow(op);
- break;
- case 'space':
- rerouted = new SpaceInsertionService(diagram, op, snap, notes).run();
- break;
- case 'tidy':
- rerouted = diagram.removeOverlaps(op, snap, notes);
- break;
- case 'compact':
- rerouted = diagram.compactDiagram(op, snap, notes);
- break;
- case 'order':
- rerouted = new LaneBandService(diagram, op, snap, notes).run();
- break;
- }
- const routed = unique(rerouted);
- const reshaped = snap.changedEdges().filter((id) => !routed.includes(id));
- return { op: op.op, index, moved: snap.changedShapes(), rerouted: routed, ...(reshaped.length ? { reshaped } : {}), ...extra, ...(notes.length ? { notes } : {}) };
- }
-}
diff --git a/src/domain/diagram/DrawnDiagram.ts b/src/domain/diagram/DrawnDiagram.ts
index c295984a..acb6320c 100644
--- a/src/domain/diagram/DrawnDiagram.ts
+++ b/src/domain/diagram/DrawnDiagram.ts
@@ -1,5 +1,5 @@
import type { Doc } from '../document/Doc.js';
-import { CliError, type ErrorDetails } from '../document/CliError.js';
+import { BpmnError, type ErrorDetails } from '../document/BpmnError.js';
import { modelError } from '../document/utils/errors.js';
import { is, type El } from '../document/utils/moddle.js';
import { isHardProblem, problemKey, type LayoutMetrics, type LayoutProblem } from './utils/problems.js';
@@ -7,31 +7,20 @@ import { LayoutAnalysis } from './di/LayoutAnalysis.js';
import { idOf, raw, rawList } from './utils/raw.js';
import { DiagramReader } from './di/DiagramReader.js';
import { Semantics } from './di/Semantics.js';
-import type { DEdge, DShape, Plane, Side } from './di/types.js';
+import type { DEdge, DShape, Plane } from './di/types.js';
import { PlaneWriter } from './di/PlaneWriter.js';
import { Drawing } from './layout/incremental/Drawing.js';
import { DrawingSnapshot } from './layout/incremental/DrawingSnapshot.js';
import { TrialGuard, type TrialPolicy } from './di/TrialGuard.js';
-import { RoomService } from './format/RoomService.js';
-import { CompactionService, type Attempt } from './format/CompactionService.js';
-import type { AlignShapes, ColorElements, CompactDiagram, PlaceLabel, RerouteFlow, TidyShapes } from '../commands/types.js';
-import { setSwatch } from './utils/colors.js';
-import { frameWord, outermost, problemWords, unique } from './utils/frames.js';
-import { copyPoints, cx, cy, inside, sameBox, samePoints, type Box } from './utils/geom.js';
-import { attachedSide } from './utils/sides.js';
-import { Frames } from './di/Frames.js';
-import { edgeLabelAt, hasExternalLabel, labelAt } from './utils/labels.js';
-import { labelSize } from './utils/text.js';
-
-const OPPOSITE: Record = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' };
+import { RoomMaker } from './commands/RoomMaker.js';
+import type { Attempt } from './commands/Compactor.js';
/** A change of one plane must not add a hard layout problem or raise the score. */
const GUARDED: TrialPolicy = { key: (problem) => (isHardProblem(problem) ? problemKey(problem) : undefined), lower: false, rebase: false };
/**
* The drawing of a document as the diagram commands change it: its planes, one drawing per
- * plane, the refusals when an element is not drawn, and the commands that need no service
- * of their own (color, route, tidy, align, label, compact). write() puts the planes back.
+ * plane and the refusals when an element is not drawn. write() puts the planes back.
*/
export class DrawnDiagram {
readonly sem: Semantics;
@@ -48,13 +37,13 @@ export class DrawnDiagram {
this.doc.invalidate();
}
- noDiagram(): CliError {
+ noDiagram(): BpmnError {
return modelError('E_NO_DIAGRAM', 'The file has no diagram; format operations need one', {
hint: 'Draw it first: `bpmn layout ` (or run the command without --no-layout, which draws a file without diagram).',
});
}
- noShape(el: El, id: string, what = 'shape'): CliError {
+ noShape(el: El, id: string, what = 'shape'): BpmnError {
const details: ErrorDetails = { element: id, hint: 'It is not drawn (written with --no-layout, or outside the drawn root): the next model edit without --no-layout places it, `bpmn layout ` redraws all.' };
if (is(el, 'bpmn:SequenceFlow') || is(el, 'bpmn:MessageFlow')) {
details.hint = `It has no line (added, or its ends changed, with --no-layout): \`bpmn route ${id}\` draws it, and so does the next model edit without --no-layout.`;
@@ -130,8 +119,8 @@ export class DrawnDiagram {
return drawing;
}
- room(plane: Plane): RoomService {
- return new RoomService(plane, this.drawing(plane));
+ room(plane: Plane): RoomMaker {
+ return new RoomMaker(plane, this.drawing(plane));
}
measure(): LayoutMetrics {
@@ -152,203 +141,4 @@ export class DrawnDiagram {
const drawing = this.drawing(plane);
return { attempt: (change, force) => drawing.attempt(trials, change, force), rejected: trials.rejected };
}
-
- colorElements(command: ColorElements): string[] {
- const colored: string[] = [];
- const swatch = command.color === 'default' ? undefined : command.color;
- for (const id of unique(command.ids ?? [])) {
- const el = this.doc.require(id);
- if (!this.planes.length) throw this.noDiagram();
- const shape = this.planes.map((plane) => plane.shapes.get(id)).find((found) => !!found);
- const edge = shape ? undefined : this.planes.map((plane) => plane.edges.get(id)).find((found) => !!found);
- const di = shape?.di ?? edge?.di;
- if (!di) throw this.noShape(el, id, 'shape or connection');
- if (setSwatch(di, swatch)) colored.push(id);
- }
- if (swatch && colored.length) {
- this.doc.declareNamespace('bioc');
- this.doc.declareNamespace('color');
- }
- return colored;
- }
-
- /** A side forced on a boundary event that points into its host would run through the host. */
- private refuseIntoHost(plane: Plane, endId: string | undefined, side: Side | undefined, role: 'exit' | 'entry', flowId: string): void {
- const end = endId ? plane.shapes.get(endId) : undefined;
- const host = end?.hostId ? plane.shapes.get(end.hostId) : undefined;
- if (!end || !host || !side) return;
- const border = attachedSide(end.bounds, host.bounds);
- if (side !== OPPOSITE[border]) return;
- throw modelError('E_INVALID_VALUE', `--${role} ${side} would run ${flowId} through ${host.id}: ${end.id} sits on the ${border} border of its host`, {
- element: flowId,
- related: [end.id, host.id],
- hint: `A boundary event's flow leaves away from its host: use --${role} ${border}${border === 'bottom' || border === 'top' ? ', left or right' : ', top or bottom'}.`,
- });
- }
-
- rerouteFlow(command: RerouteFlow): string[] {
- const { plane, edge } = this.drawnEdge(command.id, true);
- this.refuseIntoHost(plane, edge.sourceId, command.exit, 'exit', command.id);
- this.refuseIntoHost(plane, edge.targetId, command.entry, 'entry', command.id);
- const before = copyPoints(edge.points);
- const ok = this.drawing(plane).reroute(edge, { ...(command.exit ? { sourceSides: [command.exit] } : {}), ...(command.entry ? { targetSides: [command.entry] } : {}) });
- if (!ok) {
- if (!edge.di) plane.edges.delete(command.id);
- throw modelError('E_NO_SHAPE', `${command.id} cannot be routed: an end has no shape in its diagram`, { element: command.id, hint: 'Run `bpmn layout ` to draw the missing shapes.' });
- }
- this.drawing(plane).clearLabels([edge]);
- return samePoints(before, edge.points) ? [] : [command.id];
- }
-
- removeOverlaps(command: TidyShapes, snap: DrawingSnapshot, notes: string[]): string[] {
- if (!this.planes.length) throw this.noDiagram();
- const byPlane = new Map();
- if (command.ids) {
- for (const id of unique(command.ids)) {
- const { plane } = this.drawnShape(id);
- byPlane.set(plane, [...(byPlane.get(plane) ?? []), id]);
- }
- } else for (const plane of this.planes) byPlane.set(plane, []);
- const rerouted: string[] = [];
- for (const [plane, ids] of byPlane) {
- const tidied = this.drawing(plane).tidy(ids.length ? { ids } : {});
- if (tidied.unresolved.length) notes.push(`overlap left: ${tidied.unresolved.map(([first, second]) => `${first}~${second}`).join(', ')}`);
- rerouted.push(...this.drawing(plane).settle(snap));
- }
- return rerouted;
- }
-
- /** A `picked` member that would leave its frame is skipped; an explicit id is refused. */
- alignShapes(command: AlignShapes, snap: DrawingSnapshot, notes: string[], picked: ReadonlySet = new Set()): string[] {
- const refId = command.to ?? command.ids?.[0] ?? "";
- const { plane, shape: ref } = this.drawnShape(refId);
- const items = unique(command.ids ?? [])
- .filter((id) => id !== refId)
- .map((id) => this.movableShape(id));
- for (const item of items) this.samePlane(plane, item.plane, item.shape.id, refId);
- const shapes = outermost(plane, items.map((item) => item.shape));
- const moves = shapes.map((shape) => ({ shape, dx: command.axis === 'column' ? Math.round(cx(ref.bounds) - cx(shape.bounds)) : 0, dy: command.axis === 'row' ? Math.round(cy(ref.bounds) - cy(shape.bounds)) : 0 }));
- const kept: typeof moves = [];
- const left: string[] = [];
- for (const move of moves) {
- try {
- this.room(plane).checkFrames([move.shape], move.dx, move.dy, `in the ${command.axis} of ${refId}`, [ref]);
- kept.push(move);
- } catch (err) {
- if (!(err instanceof CliError) || err.code !== 'E_LEAVES_CONTAINER' || !picked.has(move.shape.id)) throw err;
- const frame = plane.shapes.get(err.details.related?.[0] ?? '');
- left.push(`${move.shape.id}${frame ? ` (${frameWord(frame)} ${frame.id})` : ''}`);
- }
- }
- if (left.length) notes.push(`left out ${left.join(', ')}: on the ${command.axis} of ${refId} ${left.length > 1 ? 'they' : 'it'} would leave ${left.length > 1 ? 'their frames' : 'its frame'}`);
- const moved = kept.filter((move) => move.dx || move.dy);
- if (!moved.length) {
- if (!left.length || kept.length) notes.push(`already aligned on the ${command.axis} of ${refId}`);
- return [];
- }
- for (const move of moved) this.drawing(plane).shift(move.shape.id, move.dx, move.dy);
- const line = (box: Box): number => (command.axis === 'column' ? cx(box) : cy(box));
- const holds = (): boolean => moved.every((move) => Math.abs(line(move.shape.bounds) - line(ref.bounds)) <= 1);
- this.room(plane).makeRoom(moved.map((move) => move.shape), [ref], snap, notes, `on the ${command.axis} of ${refId}`, holds, [command.axis === 'column' ? 'x' : 'y']);
- return this.drawing(plane).settle(snap);
- }
-
- /**
- * Above a shape at the top of a sub-process the title takes the room, so room is made below it
- * first.
- */
- placeLabel(command: PlaceLabel, snap: DrawingSnapshot, notes: string[]): { labels: string[]; rerouted: string[] } {
- const el = this.doc.require(command.id);
- const name = raw(el, 'name')?.trim();
- if (is(el, 'bpmn:SequenceFlow') || is(el, 'bpmn:MessageFlow')) {
- const { edge } = this.drawnEdge(command.id);
- if (!name) throw modelError('E_NO_LABEL', `${command.id} has no name, so it has no label`, { element: command.id, hint: `Name it first: \`bpmn set ${command.id} name=...\`.` });
- const { width, height } = edge.label ?? labelSize(name);
- const size = { width, height };
- const box = edgeLabelAt(edge, size, command.side);
- if (!box) {
- const want = command.side === 'left' || command.side === 'right' ? 'vertical' : 'horizontal';
- throw modelError('E_INVALID_VALUE', `${command.id} has no ${want} segment to put its label ${command.side === 'left' || command.side === 'right' ? 'beside' : command.side}`, {
- element: command.id,
- hint: want === 'vertical' ? 'Use side above or below.' : 'Use side left or right.',
- });
- }
- const changed = !sameBox(edge.label, box);
- edge.label = box;
- return { labels: changed ? [command.id] : [], rerouted: [] };
- }
- const { plane, shape } = this.drawnShape(command.id);
- if (!hasExternalLabel(shape)) {
- throw modelError('E_WRONG_KIND', `"${command.id}" has no external label (tasks, sub-processes, pools and lanes carry their name inside the shape)`, {
- element: command.id,
- hint: 'Labels can be placed for events, gateways, data objects / stores and flows.',
- });
- }
- if (!name) throw modelError('E_NO_LABEL', `${command.id} has no name, so it has no label`, { element: command.id, hint: `Name it first: \`bpmn set ${command.id} name=...\`.` });
- const { width, height } = shape.label ?? labelSize(name);
- const size = { width, height };
- let box = this.drawing(plane).labelOnSide(shape, command.side, size);
- let rerouted: string[] = [];
- const frame = shape.kind === 'boundary' ? undefined : Frames.of(plane, shape);
- const room = command.side === 'above' && frame?.kind === 'subProcess' && !inside(box, Frames.labelArea(frame)) ? this.drawing(plane).roomBelowTitle(frame, labelAt(shape, size, 'above'), shape.id) : undefined;
- if (room) {
- rerouted = this.drawing(plane).settle(snap);
- notes.push(`made ${room.delta} px of room above ${command.id} in its sub-process ${frame!.id} (for the label, below the title)`);
- box = this.drawing(plane).labelOnSide(shape, command.side, size);
- }
- const changed = !sameBox(shape.label, box);
- shape.label = box;
- return { labels: changed ? [command.id] : [], rerouted };
- }
-
- /** A diagram's own root (a process without pool, a drill-down) targets the whole diagram. */
- private compactionTargets(ids: readonly string[]): Map | undefined> {
- const out = new Map | undefined>();
- for (const id of unique(ids)) {
- this.doc.require(id);
- if (!this.planes.length) throw this.noDiagram();
- const root = this.planes.find((plane) => plane.rootId === id);
- if (root && !root.shapes.has(id)) {
- out.set(root, undefined);
- continue;
- }
- const { plane, shape } = this.drawnShape(id);
- if (!shape.container) {
- throw modelError('E_WRONG_KIND', `"${id}" is not a pool, lane or expanded sub-process`, {
- element: id,
- hint: 'compact takes frames: participant ids, lane ids, expanded sub-process ids (or no id for the whole drawing); to close one gap use `bpmn space --after --by -column`.',
- });
- }
- if (out.has(plane) && out.get(plane) === undefined) continue;
- out.set(plane, new Set([...(out.get(plane) ?? []), id]));
- }
- return out;
- }
-
- compactDiagram(command: CompactDiagram, snap: DrawingSnapshot, notes: string[]): string[] {
- if (!this.planes.length) throw this.noDiagram();
- const targets = command.ids ? this.compactionTargets(command.ids) : new Map(this.planes.map((plane) => [plane, undefined] as const));
- const rerouted: string[] = [];
- const total = { strips: { x: 0, y: 0 }, px: { x: 0, y: 0 }, refused: 0 };
- const rejected: LayoutProblem[] = [];
- for (const [plane, only] of targets) {
- const guard = this.guard(plane);
- const stats = new CompactionService(this.drawing(plane)).compact({ ...(only ? { only } : {}), attempt: guard.attempt });
- for (const axis of ['x', 'y'] as const) {
- total.strips[axis] += stats.strips[axis];
- total.px[axis] += stats.px[axis];
- }
- total.refused += stats.refused;
- rejected.push(...guard.rejected);
- rerouted.push(...this.drawing(plane).settle(snap));
- }
- const closedStrips = total.strips.x + total.strips.y;
- if (!closedStrips) notes.push('nothing to compact: no empty row or column to close');
- else {
- const part = (count: number, px: number, what: string): string[] => (count ? [`${count} empty ${what}${count > 1 ? 's' : ''} (${px} px)`] : []);
- notes.push(`closed ${[...part(total.strips.x, total.px.x, 'column'), ...part(total.strips.y, total.px.y, 'row')].join(' and ')}`);
- }
- if (rejected.length) notes.push(`left ${total.refused} gap${total.refused > 1 ? 's' : ''} open: closing ${total.refused > 1 ? 'them' : 'it'} would add ${problemWords(rejected)}`);
- return rerouted;
- }
}
diff --git a/src/domain/diagram/format/LaneBandService.ts b/src/domain/diagram/commands/BandReordering.ts
similarity index 94%
rename from src/domain/diagram/format/LaneBandService.ts
rename to src/domain/diagram/commands/BandReordering.ts
index 9df9b416..eefb1b00 100644
--- a/src/domain/diagram/format/LaneBandService.ts
+++ b/src/domain/diagram/commands/BandReordering.ts
@@ -12,10 +12,10 @@ import type { DrawnDiagram } from '../DrawnDiagram.js';
* The diagram part of an `order` op: lane or pool bands follow the new semantic order,
* stacked from the top of the first band, carrying their members along.
*/
-export class LaneBandService {
+export class BandReordering {
constructor(
private readonly diagram: DrawnDiagram,
- private readonly op: ReorderElements,
+ private readonly command: ReorderElements,
private readonly snap: DrawingSnapshot,
private readonly notes: string[],
) {}
@@ -40,7 +40,7 @@ export class LaneBandService {
* moves. A band whose members hang out at its bottom grows, so nothing lands in the band below.
*/
private orderBands(): string[] {
- const owner = this.diagram.doc.get(this.op.id);
+ const owner = this.diagram.doc.get(this.command.id);
const set = owner ? laneSetOf(owner) : undefined;
const lanes = rawList(set, 'lanes').map((lane) => idOf(lane)!).filter(Boolean);
const plane = this.diagram.planes.find((candidate) => lanes.length > 0 && lanes.every((id) => candidate.shapes.has(id)));
@@ -92,9 +92,9 @@ export class LaneBandService {
* process) go with the pool they are drawn in.
*/
private orderPoolBands(): string[] {
- const collab = this.diagram.doc.get(this.op.id);
+ const collab = this.diagram.doc.get(this.command.id);
const ids = rawList(collab, 'participants').map((participant) => idOf(participant)!).filter(Boolean);
- const plane = this.diagram.planes.find((candidate) => candidate.rootId === this.op.id);
+ const plane = this.diagram.planes.find((candidate) => candidate.rootId === this.command.id);
const bands = plane ? ids.map((id) => plane.shapes.get(id)).filter((shape): shape is DShape => !!shape) : [];
if (!plane || bands.length < 2) {
if (this.diagram.planes.length) this.notes.push('fewer than two pools are drawn: nothing to reorder in the diagram');
@@ -131,6 +131,6 @@ export class LaneBandService {
}
run(): string[] {
- return new ReorderTarget(this.diagram.doc, this.op).ordersPools() ? this.orderPoolBands() : this.orderBands();
+ return new ReorderTarget(this.diagram.doc, this.command).ordersPools() ? this.orderPoolBands() : this.orderBands();
}
}
diff --git a/src/domain/diagram/format/CompactionService.ts b/src/domain/diagram/commands/Compactor.ts
similarity index 99%
rename from src/domain/diagram/format/CompactionService.ts
rename to src/domain/diagram/commands/Compactor.ts
index 5b84ef10..c4bec832 100644
--- a/src/domain/diagram/format/CompactionService.ts
+++ b/src/domain/diagram/commands/Compactor.ts
@@ -59,7 +59,7 @@ export interface CompactOptions {
* columns, lane rows, gaps between pools), so frames shrink to their content while order
* and relative positions stay. Every strip goes through `attempt`, which keeps only harmless ones.
*/
-export class CompactionService {
+export class Compactor {
private readonly plane: Plane;
constructor(private readonly drawing: Drawing) {
diff --git a/src/domain/diagram/commands/DiagramCommands.ts b/src/domain/diagram/commands/DiagramCommands.ts
new file mode 100644
index 00000000..77a81eca
--- /dev/null
+++ b/src/domain/diagram/commands/DiagramCommands.ts
@@ -0,0 +1,145 @@
+import type { Doc } from '../../document/Doc.js';
+import { BpmnError } from '../../document/BpmnError.js';
+import type { DiagramCommand, ReorderElements } from '../../commands/types.js';
+import { ReorderTarget } from '../../commands/ReorderTarget.js';
+import { cx } from '../utils/geom.js';
+import type { DShape } from '../di/types.js';
+import { DrawnDiagram } from '../DrawnDiagram.js';
+import { unique } from '../utils/shape-sets.js';
+import { ShapePlacement } from './ShapePlacement.js';
+import { ShapeAlignment } from './ShapeAlignment.js';
+import { ElementColoring } from './ElementColoring.js';
+import { LabelPlacement } from './LabelPlacement.js';
+import { FlowRerouting } from './FlowRerouting.js';
+import { SpaceInsertion } from './SpaceInsertion.js';
+import { OverlapTidying } from './OverlapTidying.js';
+import { DiagramCompaction } from './DiagramCompaction.js';
+import { BandReordering } from './BandReordering.js';
+import { RoomHint } from './RoomHint.js';
+import { ElementSelector } from './ElementSelector.js';
+
+export interface DiagramCommandResult {
+ op: DiagramCommand['op'] | 'order';
+ index: number;
+ moved: string[];
+ rerouted: string[];
+ /** waypoints changed without a new route (stretched by moves, the space tool, compact) */
+ reshaped?: string[];
+ colored?: string[];
+ labels?: string[];
+ notes?: string[];
+}
+
+export interface DiagramCommandEntry {
+ op: DiagramCommand | ReorderElements;
+ index: number;
+}
+
+/**
+ * Runs diagram commands (place, align, color, label, route, space, tidy, compact, lane order)
+ * through their handlers on the drawing, in batch order; writes the planes back once at the end.
+ * A failing op throws with its batch index in `details.op`, so the batch writes nothing.
+ */
+export class DiagramCommands {
+ readonly diagram: DrawnDiagram;
+
+ constructor(readonly doc: Doc) {
+ this.diagram = new DrawnDiagram(doc);
+ }
+
+ /**
+ * `asFile`: `doc` holds the file's own drawing, so the first refusal may name a command.
+ * An `order` of a node's outgoing flows leaves the drawing alone: only bands are redrawn.
+ */
+ run(entries: readonly DiagramCommandEntry[], asFile = false): DiagramCommandResult[] {
+ const drawn = entries.filter((entry) => entry.op.op !== 'order' || new ReorderTarget(this.doc, entry.op).ordersBands());
+ if (entries.length && !drawn.length) return [];
+ const results: DiagramCommandResult[] = [];
+ for (const [position, entry] of drawn.entries()) {
+ try {
+ results.push(this.runOne(entry, asFile && position === 0 && entry.index === 0));
+ } catch (err) {
+ if (err instanceof BpmnError) err.details['op'] = entry.index;
+ throw err;
+ }
+ }
+ this.diagram.write();
+ return results;
+ }
+
+ /**
+ * Explicit ids first, then what --branch, --path and --kind name. A column align picked by
+ * --kind alone aligns on the rightmost shape, so nothing moves left. `picked`: ids only a
+ * selector named; align skips those that would leave their frame.
+ */
+ private resolveSelection(command: TCommand): { command: TCommand; picked: Set } {
+ const none = { command, picked: new Set() };
+ if (command.op !== 'place' && command.op !== 'align' && command.op !== 'color' && command.op !== 'tidy') return none;
+ if (!command.path && !command.kind && !command.branch) return none;
+ if (!this.diagram.planes.length) throw this.diagram.noDiagram();
+ const shapeOf = (id: string): DShape | undefined => this.diagram.planes.map((plane) => plane.shapes.get(id)).find((found) => !!found);
+ const ids = new ElementSelector(this.doc).select(command, {
+ shapesOnly: command.op !== 'color',
+ drawn: (id) => this.diagram.planes.some((plane) => plane.shapes.has(id) || plane.edges.has(id)),
+ centreX: (id) => {
+ const shape = shapeOf(id);
+ return shape ? cx(shape.bounds) : undefined;
+ },
+ });
+ const explicit = new Set(command.ids ?? []);
+ const picked = new Set(ids.filter((id) => !explicit.has(id)));
+ if (command.op === 'align' && !command.to && !command.ids?.length && !command.path && !command.branch && command.axis === 'column') {
+ const right = [...ids].sort((one, other) => cx(shapeOf(other)?.bounds ?? { x: 0, y: 0, width: 0, height: 0 }) - cx(shapeOf(one)?.bounds ?? { x: 0, y: 0, width: 0, height: 0 }))[0];
+ return { command: { ...command, ids, ...(right ? { to: right } : {}) }, picked };
+ }
+ return { command: { ...command, ids }, picked };
+ }
+
+ private runOne(entry: DiagramCommandEntry, asFile: boolean): DiagramCommandResult {
+ const index = entry.index;
+ const resolved = entry.op.op === 'order' ? { command: entry.op, picked: new Set() } : this.resolveSelection(entry.op);
+ const command = resolved.command;
+ const snap = this.diagram.snapshot();
+ const notes: string[] = [];
+ let rerouted: string[] = [];
+ const extra: Partial = {};
+ const diagram = this.diagram;
+ switch (command.op) {
+ case 'place': {
+ const place = () => new ShapePlacement(diagram, command, snap, notes).run();
+ rerouted = command.side ? place() : new RoomHint(diagram, command, asFile).run(place);
+ break;
+ }
+ case 'align':
+ rerouted = new RoomHint(diagram, command, asFile).run(() => new ShapeAlignment(diagram, command, snap, notes, resolved.picked).run());
+ break;
+ case 'color':
+ extra.colored = new ElementColoring(diagram, command).run();
+ break;
+ case 'label': {
+ const labelled = new LabelPlacement(diagram, command, snap, notes).run();
+ extra.labels = labelled.labels;
+ rerouted = labelled.rerouted;
+ break;
+ }
+ case 'route':
+ rerouted = new FlowRerouting(diagram, command).run();
+ break;
+ case 'space':
+ rerouted = new SpaceInsertion(diagram, command, snap, notes).run();
+ break;
+ case 'tidy':
+ rerouted = new OverlapTidying(diagram, command, snap, notes).run();
+ break;
+ case 'compact':
+ rerouted = new DiagramCompaction(diagram, command, snap, notes).run();
+ break;
+ case 'order':
+ rerouted = new BandReordering(diagram, command, snap, notes).run();
+ break;
+ }
+ const routed = unique(rerouted);
+ const reshaped = snap.changedEdges().filter((id) => !routed.includes(id));
+ return { op: command.op, index, moved: snap.changedShapes(), rerouted: routed, ...(reshaped.length ? { reshaped } : {}), ...extra, ...(notes.length ? { notes } : {}) };
+ }
+}
diff --git a/src/domain/diagram/commands/DiagramCompaction.ts b/src/domain/diagram/commands/DiagramCompaction.ts
new file mode 100644
index 00000000..5597432c
--- /dev/null
+++ b/src/domain/diagram/commands/DiagramCompaction.ts
@@ -0,0 +1,69 @@
+import { modelError } from '../../document/utils/errors.js';
+import type { CompactDiagram } from '../../commands/types.js';
+import type { Plane } from '../di/types.js';
+import type { DrawingSnapshot } from '../layout/incremental/DrawingSnapshot.js';
+import type { LayoutProblem } from '../utils/problems.js';
+import { problemWords, unique } from '../utils/shape-sets.js';
+import type { DrawnDiagram } from '../DrawnDiagram.js';
+import { Compactor } from './Compactor.js';
+
+export class DiagramCompaction {
+ constructor(
+ private readonly diagram: DrawnDiagram,
+ private readonly command: CompactDiagram,
+ private readonly snap: DrawingSnapshot,
+ private readonly notes: string[],
+ ) {}
+
+ /** A diagram's own root (a process without pool, a drill-down) targets the whole diagram. */
+ private compactionTargets(ids: readonly string[]): Map | undefined> {
+ const out = new Map | undefined>();
+ for (const id of unique(ids)) {
+ this.diagram.doc.require(id);
+ if (!this.diagram.planes.length) throw this.diagram.noDiagram();
+ const root = this.diagram.planes.find((plane) => plane.rootId === id);
+ if (root && !root.shapes.has(id)) {
+ out.set(root, undefined);
+ continue;
+ }
+ const { plane, shape } = this.diagram.drawnShape(id);
+ if (!shape.container) {
+ throw modelError('E_WRONG_KIND', `"${id}" is not a pool, lane or expanded sub-process`, {
+ element: id,
+ hint: 'compact takes frames: participant ids, lane ids, expanded sub-process ids (or no id for the whole drawing); to close one gap use `bpmn space