From 759a89e815d3225dbd2cdb7af2406ecd7058a289 Mon Sep 17 00:00:00 2001 From: Marco Schaeck Date: Wed, 28 Oct 2026 12:38:44 +0100 Subject: [PATCH 01/30] chore: build against a locally packed @miragon/bpmnlint-plugin-rules The Camunda 7 / 8 engine rules need the ./engine, ./camunda-7 and ./camunda-8 entry points of Miragon/bpmnlint-rules#113, which is not released yet. Swap the tarball for the released version before merging. --- package-lock.json | 8 +++++--- package.json | 4 +++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9837b946..36cb5797 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,12 @@ "version": "0.8.0", "license": "MIT", "dependencies": { - "@miragon/bpmnlint-plugin-rules": "0.19.0", + "@miragon/bpmnlint-plugin-rules": "file:miragon-bpmnlint-plugin-rules-0.19.0.tgz", "@resvg/resvg-wasm": "2.6.2", "bpmn-moddle": "^10.2.0", "bpmnlint": "11.15.0", + "bpmnlint-plugin-camunda-compat": "2.61.1", + "bpmnlint-utils": "1.1.1", "commander": "^15.0.0", "moddle": "^8.2.1" }, @@ -792,8 +794,8 @@ }, "node_modules/@miragon/bpmnlint-plugin-rules": { "version": "0.19.0", - "resolved": "https://registry.npmjs.org/@miragon/bpmnlint-plugin-rules/-/bpmnlint-plugin-rules-0.19.0.tgz", - "integrity": "sha512-jjCIu2qL3gDfg82HzVKZEoCfKZVWwaLNHyRP5JVwD6nq5VgHG0ZOcXFwD6ROoueRqjn3NI2nbTViK4mv7PLZeQ==", + "resolved": "file:miragon-bpmnlint-plugin-rules-0.19.0.tgz", + "integrity": "sha512-rSmA78luiCrD9tHe7WtIBJ7qmv2OAmOIGCcHqfTowSR1kQgIPquw8LTvVNqt4fC2m1lLdiKlvytwDTYtio28xQ==", "license": "MIT", "workspaces": [ "docs" diff --git a/package.json b/package.json index 6e3c3f19..19c8cf60 100644 --- a/package.json +++ b/package.json @@ -67,10 +67,12 @@ "architecture": "depcruise src --config .dependency-cruiser.cjs" }, "dependencies": { - "@miragon/bpmnlint-plugin-rules": "0.19.0", + "@miragon/bpmnlint-plugin-rules": "file:miragon-bpmnlint-plugin-rules-0.19.0.tgz", "@resvg/resvg-wasm": "2.6.2", "bpmn-moddle": "^10.2.0", "bpmnlint": "11.15.0", + "bpmnlint-plugin-camunda-compat": "2.61.1", + "bpmnlint-utils": "1.1.1", "commander": "^15.0.0", "moddle": "^8.2.1" }, From 8a1cb5ed9779d95d4563690b44529bb972580125 Mon Sep 17 00:00:00 2001 From: Marco Schaeck Date: Wed, 28 Oct 2026 12:38:44 +0100 Subject: [PATCH 02/30] refactor!: run the Camunda 7/8 engine rules as bpmnlint rules The W_C7_* / W_C8_* checks no longer come from the domain's own profiles (domain/validation/profiles). The domain asks an EngineLint port; the bpmnlint adapter answers it with camunda-compat's config for the file's engine version plus bpmn-cli's own rules for what camunda-compat leaves out. The own rules live in adapters/out/bpmnlint/engine/rules/c7|c8, are written on EngineRule of @miragon/bpmnlint-plugin-rules/engine and run through its camunda7 / camunda8.withRules. Each engine loads as a chunk of its own. The findings keep their codes and hints: the own rules give the hint with their report, camunda-compat findings get one from where they point (EngineHint, CompatHints). Where camunda-compat reads a file differently than Camunda 8.9 (engine-checked), CompatVerdicts follows the engine. The profile tests of main pass unchanged apart from the intended differences listed in docs/camunda.md (camunda-compat's wording, findings reported on the flow or target instead of the gateway, W_C8_DEPLOY_UNSUPPORTED for an event without definition). The on-demand chunk budgets grow (C7 127 KB, C8 367 KB, engine run 49 KB minified) for the hint texts and the camunda-compat rules; the entry budgets are unchanged. BREAKING CHANGE: loadProfile, loadProfiles, runProfile and ProfileReport are no longer exported; validateDoc(doc) runs the structure alone, the engine rules run in checkDoc, mutateDoc and the in-memory API. --- CONTRIBUTING.md | 15 +- docs/bpmnlint.md | 5 + docs/camunda.md | 141 +- docs/library.md | 33 +- docs/testing.md | 88 +- src/adapters/in/report/command-report.ts | 2 +- src/adapters/in/report/guide.ts | 11 +- .../out/bpmnlint/BpmnlintEngineLint.ts | 56 + .../out/bpmnlint/bpmnlint-contract.ts | 5 + src/adapters/out/bpmnlint/engine-kit.ts | 47 + .../out/bpmnlint/engine}/BpmnSchema.ts | 0 .../out/bpmnlint/engine/CamundaBooleans.ts | 76 + .../out/bpmnlint/engine/CompatHints.ts | 77 + .../out/bpmnlint/engine/CompatVerdicts.ts | 91 + .../out/bpmnlint/engine/EngineCodes.ts | 155 ++ .../out/bpmnlint/engine/EngineFindings.ts | 123 ++ .../out/bpmnlint/engine/EngineHint.ts | 291 ++++ src/adapters/out/bpmnlint/engine/EngineRun.ts | 207 +++ .../out/bpmnlint/engine/PublishMessages.ts | 63 + .../out/bpmnlint/engine}/SchemaText.ts | 10 +- .../out/bpmnlint/engine/SchemaTextFindings.ts | 85 + .../bpmnlint/engine/UnknownVendorElements.ts | 194 +++ .../bpmnlint/engine/UnprefixedAttributes.ts | 102 ++ .../out/bpmnlint/engine/rules/CamundaRules.ts | 16 + .../rules/CompensationHandlerBoundary.ts | 30 + .../engine/rules/DiagramNamespaces.ts | 56 + .../out/bpmnlint/engine/rules/LinkEvents.ts | 73 + .../out/bpmnlint/engine/rules/c7/C7Diagram.ts | 46 + .../engine/rules/c7/c7-ad-hoc-sub-process.ts | 89 + .../rules/c7/c7-async-duplicate-extension.ts | 20 + .../engine/rules/c7/c7-attribute-value.ts | 105 ++ .../bpmnlint/engine/rules/c7/c7-bad-value.ts | 143 ++ .../engine/rules/c7/c7-binding-version.ts | 68 + .../engine/rules/c7/c7-boundary-host.ts | 19 + .../engine/rules/c7/c7-called-element.ts | 48 + .../engine/rules/c7/c7-complex-gateway.ts | 40 + .../engine/rules/c7/c7-dangling-reference.ts | 89 + .../rules/c7/c7-definitions-extension.ts | 41 + .../engine/rules/c7/c7-duplicate-extension.ts | 20 + .../engine/rules/c7/c7-empty-condition.ts | 85 + .../out/bpmnlint/engine/rules/c7/c7-error.ts | 53 + .../bpmnlint/engine/rules/c7/c7-escalation.ts | 90 + .../engine/rules/c7/c7-event-definition.ts | 157 ++ .../engine/rules/c7/c7-event-gateway.ts | 153 ++ .../engine/rules/c7/c7-exclusive-gateway.ts | 127 ++ .../bpmnlint/engine/rules/c7/c7-extension.ts | 345 ++++ .../engine/rules/c7/c7-external-topic.ts | 31 + .../engine/rules/c7/c7-foreign-content.ts | 78 + .../out/bpmnlint/engine/rules/c7/c7-form.ts | 78 + .../rules/c7/c7-history-time-to-live.ts | 47 + .../engine/rules/c7/c7-implementation.ts | 157 ++ .../rules/c7/c7-implicit-default-flow.ts | 40 + .../engine/rules/c7/c7-input-output.ts | 101 ++ .../bpmnlint/engine/rules/c7/c7-link-event.ts | 120 ++ .../bpmnlint/engine/rules/c7/c7-listener.ts | 149 ++ .../rules/c7/c7-message-implementation.ts | 50 + .../bpmnlint/engine/rules/c7/c7-message.ts | 116 ++ .../engine/rules/c7/c7-misplaced-attribute.ts | 121 ++ .../engine/rules/c7/c7-misplaced-extension.ts | 83 + .../engine/rules/c7/c7-multi-instance.ts | 106 ++ .../rules/c7/c7-multiple-event-definitions.ts | 59 + .../engine/rules/c7/c7-result-variable.ts | 47 + .../out/bpmnlint/engine/rules/c7/c7-script.ts | 39 + .../out/bpmnlint/engine/rules/c7/c7-signal.ts | 121 ++ .../engine/rules/c7/c7-start-event.ts | 94 + .../engine/rules/c7/c7-transaction-start.ts | 42 + .../engine/rules/c7/c7-unknown-attribute.ts | 105 ++ .../rules/c7/events/C7DefinitionSubject.ts | 69 + .../rules/c7/events/C7EventDefinitions.ts | 93 + .../rules/c7/events/C7ExecutableScope.ts | 36 + .../rules/c7/events/C7ExtraStartHint.ts | 148 ++ .../engine/rules/c7/events/C7Subscriptions.ts | 127 ++ .../rules/c7/events/missingStartHint.ts | 10 + .../engine/rules/c7/extensions/C7Host.ts | 227 +++ .../engine/rules/c7/extensions/C7HostRule.ts | 32 + .../rules/c7/extensions/CamundaDescriptor.ts | 246 +++ .../rules/c7/extensions/CamundaDuplicates.ts | 70 + .../c7/extensions/CamundaDuplicatesRule.ts | 42 + .../rules/c7/extensions/CamundaElementTree.ts | 71 + .../rules/c7/extensions/CamundaPlacement.ts | 150 ++ .../rules/c7/extensions/ExtensionHints.ts | 105 ++ .../c7/extensions/UnresolvedReferences.ts | 39 + .../c7/extensions/utils/attributeValue.ts | 16 + .../c7/extensions/utils/extAddArguments.ts | 62 + .../rules/c7/extensions/utils/kindsText.ts | 8 + .../engine/rules/c7/flows/C7FlowGraph.ts | 77 + .../engine/rules/c7/flows/C7FlowRule.ts | 22 + .../c7/implementation/C7ExecutableRule.ts | 22 + .../c7/implementation/CamundaAttributes.ts | 32 + .../c7/implementation/ImplementationHost.ts | 126 ++ .../c7/implementation/ReferenceBinding.ts | 76 + .../rules/c7/implementation/SetCommand.ts | 33 + .../rules/c7/implementation/utils/values.ts | 14 + .../out/bpmnlint/engine/rules/c7/index.ts | 62 + .../engine/rules/c8/c8-ad-hoc-sub-process.ts | 37 + .../bpmnlint/engine/rules/c8/c8-bad-value.ts | 122 ++ .../engine/rules/c8/c8-boundary-host.ts | 14 + .../engine/rules/c8/c8-duplicate-extension.ts | 56 + .../out/bpmnlint/engine/rules/c8/c8-error.ts | 96 ++ .../engine/rules/c8/c8-event-definition.ts | 199 +++ .../engine/rules/c8/c8-event-gateway.ts | 102 ++ .../bpmnlint/engine/rules/c8/c8-extension.ts | 267 +++ .../rules/c8/c8-feel-completion-condition.ts | 44 + .../engine/rules/c8/c8-feel-expression.ts | 455 +++++ .../engine/rules/c8/c8-foreign-content.ts | 91 + .../engine/rules/c8/c8-implementation.ts | 49 + .../bpmnlint/engine/rules/c8/c8-link-event.ts | 83 + .../bpmnlint/engine/rules/c8/c8-listener.ts | 227 +++ .../bpmnlint/engine/rules/c8/c8-message.ts | 52 + .../engine/rules/c8/c8-misplaced-attribute.ts | 47 + .../engine/rules/c8/c8-misplaced-extension.ts | 125 ++ .../out/bpmnlint/engine/rules/c8/c8-schema.ts | 134 ++ .../out/bpmnlint/engine/rules/c8/c8-signal.ts | 33 + .../engine/rules/c8/c8-start-event.ts | 67 + .../engine/rules/c8/c8-timer-value.ts | 86 + .../engine/rules/c8/c8-unknown-attribute.ts | 107 ++ .../engine/rules/c8/events/C8EventPosition.ts | 108 ++ .../c8/events/C8NamedSubscriptionRule.ts | 77 + .../rules/c8/events/C8SubscriptionRule.ts | 61 + .../engine/rules/c8/events/FlowIndex.ts | 36 + .../rules/c8/events/SubscriptionGroups.ts | 106 ++ .../engine/rules/c8/events}/TimerValue.ts | 0 .../engine/rules/c8/events/utils/c8Values.ts | 14 + .../rules/c8/extensions/C8CaughtMessages.ts | 60 + .../rules/c8/extensions/C8CheckedElement.ts | 187 ++ .../engine/rules/c8/extensions/ZeebeHint.ts | 134 ++ .../engine/rules/c8/extensions/ZeebeSchema.ts | 243 +++ .../engine/rules/c8/extensions/namespaces.ts | 7 + .../rules/c8/extensions/utils/pathFrom.ts | 23 + .../engine/rules/c8/feel}/FeelError.ts | 0 .../engine/rules/c8/feel}/FeelExpression.ts | 6 +- .../engine/rules/c8/feel}/FeelParser.ts | 251 ++- .../engine/rules/c8/feel/ZeebeFixHint.ts | 89 + .../bpmnlint/engine/rules/c8/feel/feelOf.ts | 18 + .../out/bpmnlint/engine/rules/c8/index.ts | 39 + .../engine/rules/c8/tasks/C8Events.ts | 45 + .../engine/rules/c8/tasks/ExecutableScope.ts | 66 + .../engine/rules/c8/tasks/ModdlePath.ts | 56 + .../engine/rules/c8/tasks/ZeebeExtensions.ts | 41 + .../engine/rules/c8/tasks/utils/value.ts | 14 + .../out/bpmnlint/engine/rules/describe.ts | 37 + .../out/bpmnlint/engine/rules/didYouMean.ts | 37 + .../out/bpmnlint/engine/rules/shell.ts | 14 + src/api.ts | 18 +- .../port/in/ValidateDocumentUseCase.ts | 37 +- src/application/port/out/EngineLintPort.ts | 4 + .../service/CreateDocumentService.ts | 4 +- .../service/EditDocumentService.ts | 4 +- .../service/RelayoutDiagramService.ts | 4 +- .../service/ValidateDocumentService.ts | 22 +- src/cli.ts | 14 +- src/domain/batch/EditBatch.ts | 11 +- src/domain/batch/types.ts | 3 + .../document/vendor/DocumentNamespaces.ts | 12 +- .../document/vendor/PlatformDetection.ts | 11 +- .../editing/properties/DecisionLinkWriter.ts | 2 +- src/domain/validation/DocumentCheck.ts | 12 +- src/domain/validation/EngineLint.ts | 14 + src/domain/validation/FindingBaseline.ts | 11 +- src/domain/validation/PlatformCheck.ts | 34 + src/domain/validation/StructuralValidator.ts | 21 +- src/domain/validation/profiles/C7Profile.ts | 94 - src/domain/validation/profiles/C8Profile.ts | 92 - .../validation/profiles/EngineProfile.ts | 50 - src/domain/validation/profiles/EngineRules.ts | 115 -- src/domain/validation/profiles/LinkEvents.ts | 61 - .../validation/profiles/PlatformProfiles.ts | 108 -- src/domain/validation/profiles/RuleRun.ts | 33 - .../profiles/c7/C7DescriptorNames.ts | 52 - .../validation/profiles/c7/C7EventRules.ts | 417 ----- .../profiles/c7/C7ExtensionContentRules.ts | 156 -- .../profiles/c7/C7ExtensionRules.ts | 313 ---- .../validation/profiles/c7/C7FlowRules.ts | 137 -- .../profiles/c7/C7ImplementationRules.ts | 292 ---- src/domain/validation/profiles/c7/C7Rules.ts | 161 -- .../validation/profiles/c7/C7SchemaRules.ts | 80 - .../validation/profiles/c8/C8EventRules.ts | 445 ----- .../profiles/c8/C8ExtensionRules.ts | 392 ----- .../validation/profiles/c8/C8FlowRules.ts | 102 -- src/domain/validation/profiles/c8/C8Rules.ts | 91 - .../validation/profiles/c8/C8SchemaRules.ts | 105 -- .../validation/profiles/c8/C8TaskRules.ts | 174 -- src/domain/validation/profiles/rule-kit.ts | 41 - src/domain/validation/utils/c7-hint.ts | 27 - src/domain/validation/utils/c8-value.ts | 38 - src/domain/validation/utils/element.ts | 42 - src/domain/validation/utils/hint.ts | 44 - src/domain/validation/utils/value.ts | 3 - src/index.ts | 41 +- src/node.ts | 12 +- src/types/engine-rules.d.ts | 99 ++ src/types/vendor.d.ts | 6 + .../out/bpmnlint/engine-codes.test.ts | 34 + .../bpmnlint/engine/FeelExpression.test.ts | 44 + .../engine/c7/c7-ad-hoc-sub-process.test.ts | 142 ++ .../c7/c7-async-duplicate-extension.test.ts | 51 + .../engine/c7/c7-attribute-value.test.ts | 150 ++ .../bpmnlint/engine/c7/c7-bad-value.test.ts | 170 ++ .../engine/c7/c7-binding-version.test.ts | 131 ++ .../engine/c7/c7-boundary-host.test.ts | 60 + .../engine/c7/c7-called-element.test.ts | 68 + .../engine/c7/c7-complex-gateway.test.ts | 55 + .../engine/c7/c7-dangling-reference.test.ts | 75 + .../c7/c7-definitions-extension.test.ts | 56 + .../engine/c7/c7-duplicate-extension.test.ts | 129 ++ .../engine/c7/c7-empty-condition.test.ts | 157 ++ .../out/bpmnlint/engine/c7/c7-error.test.ts | 79 + .../bpmnlint/engine/c7/c7-escalation.test.ts | 102 ++ .../engine/c7/c7-event-definition.test.ts | 210 +++ .../engine/c7/c7-event-gateway.test.ts | 161 ++ .../engine/c7/c7-exclusive-gateway.test.ts | 112 ++ .../bpmnlint/engine/c7/c7-extension.test.ts | 255 +++ .../engine/c7/c7-external-topic.test.ts | 74 + .../engine/c7/c7-foreign-content.test.ts | 119 ++ .../out/bpmnlint/engine/c7/c7-form.test.ts | 78 + .../engine/c7/c7-history-time-to-live.test.ts | 39 + .../engine/c7/c7-implementation.test.ts | 232 +++ .../c7/c7-implicit-default-flow.test.ts | 66 + .../engine/c7/c7-input-output.test.ts | 136 ++ .../bpmnlint/engine/c7/c7-link-event.test.ts | 129 ++ .../bpmnlint/engine/c7/c7-listener.test.ts | 173 ++ .../c7/c7-message-implementation.test.ts | 93 + .../out/bpmnlint/engine/c7/c7-message.test.ts | 151 ++ .../engine/c7/c7-misplaced-attribute.test.ts | 177 ++ .../engine/c7/c7-misplaced-extension.test.ts | 159 ++ .../engine/c7/c7-multi-instance.test.ts | 111 ++ .../c7/c7-multiple-event-definitions.test.ts | 158 ++ .../engine/c7/c7-result-variable.test.ts | 64 + .../out/bpmnlint/engine/c7/c7-script.test.ts | 60 + .../out/bpmnlint/engine/c7/c7-signal.test.ts | 158 ++ .../bpmnlint/engine/c7/c7-start-event.test.ts | 188 ++ .../engine/c7/c7-transaction-start.test.ts | 74 + .../engine/c7/c7-unknown-attribute.test.ts | 134 ++ .../engine/c8/c8-ad-hoc-sub-process.test.ts | 76 + .../bpmnlint/engine/c8/c8-bad-value.test.ts | 147 ++ .../engine/c8/c8-boundary-host.test.ts | 54 + .../engine/c8/c8-duplicate-extension.test.ts | 177 ++ .../out/bpmnlint/engine/c8/c8-error.test.ts | 155 ++ .../engine/c8/c8-event-definition.test.ts | 289 ++++ .../engine/c8/c8-event-gateway.test.ts | 115 ++ .../bpmnlint/engine/c8/c8-extension.test.ts | 210 +++ .../c8/c8-feel-completion-condition.test.ts | 85 + .../engine/c8/c8-feel-expression.test.ts | 492 ++++++ .../engine/c8/c8-foreign-content.test.ts | 113 ++ .../engine/c8/c8-implementation.test.ts | 89 + .../bpmnlint/engine/c8/c8-link-event.test.ts | 137 ++ .../bpmnlint/engine/c8/c8-listener.test.ts | 251 +++ .../out/bpmnlint/engine/c8/c8-message.test.ts | 196 +++ .../engine/c8/c8-misplaced-attribute.test.ts | 67 + .../engine/c8/c8-misplaced-extension.test.ts | 173 ++ .../out/bpmnlint/engine/c8/c8-schema.test.ts | 154 ++ .../out/bpmnlint/engine/c8/c8-signal.test.ts | 109 ++ .../bpmnlint/engine/c8/c8-start-event.test.ts | 113 ++ .../engine/c8/c8-unknown-attribute.test.ts | 132 ++ test/adapters/out/files/bpmnlint.test.ts | 2 +- test/domain/editing/c8-ops.test.ts | 9 +- .../editing/properties/decision-link.test.ts | 17 +- .../validation/c7-profile-rules.test.ts | 48 +- test/domain/validation/c7-profile.test.ts | 51 +- .../domain/validation/c7-schema-rules.test.ts | 57 +- .../validation/c8-feel-and-deploy.test.ts | 66 +- test/domain/validation/c8-profile.test.ts | 89 +- test/domain/validation/c8-values.test.ts | 10 +- test/e2e/camunda7-rules.test.ts | 32 +- test/e2e/camunda7.test.ts | 8 +- test/e2e/isomorphic.test.ts | 54 +- test/fixtures/c8/feel-verdicts.json | 1524 ++++++++--------- test/helpers/engine-model.ts | 128 ++ test/helpers/engine.ts | 20 + test/helpers/helpers.ts | 10 +- tools/iso/budget.json | 24 +- tools/iso/bundle.mjs | 23 +- tools/iso/check.mjs | 38 +- 273 files changed, 21224 insertions(+), 5046 deletions(-) create mode 100644 src/adapters/out/bpmnlint/BpmnlintEngineLint.ts create mode 100644 src/adapters/out/bpmnlint/engine-kit.ts rename src/{domain/validation/profiles => adapters/out/bpmnlint/engine}/BpmnSchema.ts (100%) create mode 100644 src/adapters/out/bpmnlint/engine/CamundaBooleans.ts create mode 100644 src/adapters/out/bpmnlint/engine/CompatHints.ts create mode 100644 src/adapters/out/bpmnlint/engine/CompatVerdicts.ts create mode 100644 src/adapters/out/bpmnlint/engine/EngineCodes.ts create mode 100644 src/adapters/out/bpmnlint/engine/EngineFindings.ts create mode 100644 src/adapters/out/bpmnlint/engine/EngineHint.ts create mode 100644 src/adapters/out/bpmnlint/engine/EngineRun.ts create mode 100644 src/adapters/out/bpmnlint/engine/PublishMessages.ts rename src/{domain/validation/profiles => adapters/out/bpmnlint/engine}/SchemaText.ts (95%) create mode 100644 src/adapters/out/bpmnlint/engine/SchemaTextFindings.ts create mode 100644 src/adapters/out/bpmnlint/engine/UnknownVendorElements.ts create mode 100644 src/adapters/out/bpmnlint/engine/UnprefixedAttributes.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/CamundaRules.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/CompensationHandlerBoundary.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/DiagramNamespaces.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/LinkEvents.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/C7Diagram.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-ad-hoc-sub-process.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-async-duplicate-extension.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-attribute-value.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-bad-value.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-binding-version.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-boundary-host.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-called-element.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-complex-gateway.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-dangling-reference.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-definitions-extension.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-duplicate-extension.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-empty-condition.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-error.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-escalation.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-event-definition.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-event-gateway.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-exclusive-gateway.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-extension.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-external-topic.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-foreign-content.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-form.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-history-time-to-live.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-implementation.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-implicit-default-flow.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-input-output.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-link-event.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-listener.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-message-implementation.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-message.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-misplaced-attribute.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-misplaced-extension.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-multi-instance.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-multiple-event-definitions.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-result-variable.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-script.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-signal.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-start-event.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-transaction-start.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/c7-unknown-attribute.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/events/C7DefinitionSubject.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/events/C7EventDefinitions.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/events/C7ExecutableScope.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/events/C7ExtraStartHint.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/events/C7Subscriptions.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/events/missingStartHint.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/extensions/C7Host.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/extensions/C7HostRule.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/extensions/CamundaDescriptor.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/extensions/CamundaDuplicates.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/extensions/CamundaDuplicatesRule.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/extensions/CamundaElementTree.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/extensions/CamundaPlacement.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/extensions/ExtensionHints.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/extensions/UnresolvedReferences.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/extensions/utils/attributeValue.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/extensions/utils/extAddArguments.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/extensions/utils/kindsText.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/flows/C7FlowGraph.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/flows/C7FlowRule.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/implementation/C7ExecutableRule.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/implementation/CamundaAttributes.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/implementation/ImplementationHost.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/implementation/ReferenceBinding.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/implementation/SetCommand.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/implementation/utils/values.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c7/index.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/c8-ad-hoc-sub-process.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/c8-bad-value.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/c8-boundary-host.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/c8-duplicate-extension.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/c8-error.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/c8-event-definition.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/c8-event-gateway.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/c8-extension.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/c8-feel-completion-condition.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/c8-feel-expression.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/c8-foreign-content.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/c8-implementation.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/c8-link-event.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/c8-listener.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/c8-message.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/c8-misplaced-attribute.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/c8-misplaced-extension.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/c8-schema.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/c8-signal.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/c8-start-event.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/c8-timer-value.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/c8-unknown-attribute.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/events/C8EventPosition.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/events/C8NamedSubscriptionRule.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/events/C8SubscriptionRule.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/events/FlowIndex.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/events/SubscriptionGroups.ts rename src/{domain/validation/profiles => adapters/out/bpmnlint/engine/rules/c8/events}/TimerValue.ts (100%) create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/events/utils/c8Values.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/extensions/C8CaughtMessages.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/extensions/C8CheckedElement.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/extensions/ZeebeHint.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/extensions/ZeebeSchema.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/extensions/namespaces.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/extensions/utils/pathFrom.ts rename src/{domain/validation/profiles => adapters/out/bpmnlint/engine/rules/c8/feel}/FeelError.ts (100%) rename src/{domain/validation/profiles => adapters/out/bpmnlint/engine/rules/c8/feel}/FeelExpression.ts (96%) rename src/{domain/validation/profiles => adapters/out/bpmnlint/engine/rules/c8/feel}/FeelParser.ts (77%) create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/feel/ZeebeFixHint.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/feel/feelOf.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/index.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/tasks/C8Events.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/tasks/ExecutableScope.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/tasks/ModdlePath.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/tasks/ZeebeExtensions.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/c8/tasks/utils/value.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/describe.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/didYouMean.ts create mode 100644 src/adapters/out/bpmnlint/engine/rules/shell.ts create mode 100644 src/application/port/out/EngineLintPort.ts create mode 100644 src/domain/validation/EngineLint.ts create mode 100644 src/domain/validation/PlatformCheck.ts delete mode 100644 src/domain/validation/profiles/C7Profile.ts delete mode 100644 src/domain/validation/profiles/C8Profile.ts delete mode 100644 src/domain/validation/profiles/EngineProfile.ts delete mode 100644 src/domain/validation/profiles/EngineRules.ts delete mode 100644 src/domain/validation/profiles/LinkEvents.ts delete mode 100644 src/domain/validation/profiles/PlatformProfiles.ts delete mode 100644 src/domain/validation/profiles/RuleRun.ts delete mode 100644 src/domain/validation/profiles/c7/C7DescriptorNames.ts delete mode 100644 src/domain/validation/profiles/c7/C7EventRules.ts delete mode 100644 src/domain/validation/profiles/c7/C7ExtensionContentRules.ts delete mode 100644 src/domain/validation/profiles/c7/C7ExtensionRules.ts delete mode 100644 src/domain/validation/profiles/c7/C7FlowRules.ts delete mode 100644 src/domain/validation/profiles/c7/C7ImplementationRules.ts delete mode 100644 src/domain/validation/profiles/c7/C7Rules.ts delete mode 100644 src/domain/validation/profiles/c7/C7SchemaRules.ts delete mode 100644 src/domain/validation/profiles/c8/C8EventRules.ts delete mode 100644 src/domain/validation/profiles/c8/C8ExtensionRules.ts delete mode 100644 src/domain/validation/profiles/c8/C8FlowRules.ts delete mode 100644 src/domain/validation/profiles/c8/C8Rules.ts delete mode 100644 src/domain/validation/profiles/c8/C8SchemaRules.ts delete mode 100644 src/domain/validation/profiles/c8/C8TaskRules.ts delete mode 100644 src/domain/validation/profiles/rule-kit.ts delete mode 100644 src/domain/validation/utils/c7-hint.ts delete mode 100644 src/domain/validation/utils/c8-value.ts delete mode 100644 src/domain/validation/utils/element.ts delete mode 100644 src/domain/validation/utils/hint.ts delete mode 100644 src/domain/validation/utils/value.ts create mode 100644 src/types/engine-rules.d.ts create mode 100644 test/adapters/out/bpmnlint/engine-codes.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/FeelExpression.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-ad-hoc-sub-process.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-async-duplicate-extension.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-attribute-value.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-bad-value.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-binding-version.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-boundary-host.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-called-element.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-complex-gateway.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-dangling-reference.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-definitions-extension.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-duplicate-extension.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-empty-condition.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-error.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-escalation.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-event-definition.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-event-gateway.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-exclusive-gateway.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-extension.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-external-topic.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-foreign-content.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-form.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-history-time-to-live.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-implementation.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-implicit-default-flow.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-input-output.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-link-event.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-listener.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-message-implementation.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-message.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-misplaced-attribute.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-misplaced-extension.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-multi-instance.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-multiple-event-definitions.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-result-variable.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-script.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-signal.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-start-event.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-transaction-start.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c7/c7-unknown-attribute.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c8/c8-ad-hoc-sub-process.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c8/c8-bad-value.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c8/c8-boundary-host.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c8/c8-duplicate-extension.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c8/c8-error.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c8/c8-event-definition.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c8/c8-event-gateway.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c8/c8-extension.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c8/c8-feel-completion-condition.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c8/c8-feel-expression.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c8/c8-foreign-content.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c8/c8-implementation.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c8/c8-link-event.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c8/c8-listener.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c8/c8-message.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c8/c8-misplaced-attribute.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c8/c8-misplaced-extension.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c8/c8-schema.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c8/c8-signal.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c8/c8-start-event.test.ts create mode 100644 test/adapters/out/bpmnlint/engine/c8/c8-unknown-attribute.test.ts create mode 100644 test/helpers/engine-model.ts create mode 100644 test/helpers/engine.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2ccdf87f..41a5ad82 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,16 +53,16 @@ them on a private corpus without copying it into the repository, are described i | `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, `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/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; `PlatformCheck` adds the Camunda 7 / 8 findings of the injected `EngineLint` | | `domain/views/` | the read views (`show`, `find`, `--around`, `--context`) and `DrawableDoc`, a document ready to draw (one without a diagram laid out in memory) | | `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`), `RenderDiagramPort` (a plane as SVG), `WriteImagePort` | + | `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`), `RenderDiagramPort` (a plane as SVG), `EngineLintPort` (the Camunda 7 / 8 rules), `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/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 via resvg) | + | `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`); `BpmnlintEngineLint`, the Camunda 7 / 8 rules (the `EngineLintPort`), which loads `engine/` on demand: camunda-compat and bpmn-cli's own rules (`engine/rules/c7|c8/`, written on `EngineRule` of `@miragon/bpmnlint-plugin-rules/engine` and run with the plugin's `camunda7` / `camunda8.withRules`), mapped to `W_C7_*` / `W_C8_*` (`EngineCodes`, `EngineHint`, `CompatVerdicts`, `CompatHints`), and the checks a typed parse cannot see; `engine/` gets the core from `engine-kit.ts` (a rule chunk must not import the entry) | + | `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/render/` | the SVG renderer behind the `RenderDiagramPort`, browser-safe: `SvgRenderer` (the port), `DocRenderer` (one plane, start to end), `crop/` (which part of the plane), `paint/` (the shapes, connections and labels as SVG), `compose/` (frame, scale, badges, tiles) | | `adapters/out/memory/` | the driven adapters of the in-memory API: `NoDocumentFiles`, `NoProjectSettings` | @@ -119,9 +119,10 @@ them on a private corpus without copying it into the repository, are described i `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 -[bpmnlint](docs/bpmnlint.md)). Both are pinned exactly: they decide which findings an agent sees, +`@miragon/bpmnlint-plugin-rules` (bpmnlint's rules, camunda-compat and Miragon's rules behind one +resolver). They run when a `.bpmnlintrc` applies (see [bpmnlint](docs/bpmnlint.md)), and the plugin's +`./camunda-7` and `./camunda-8` entries are the Camunda 7 / 8 rules of every check, loaded on demand +(see [Camunda 7 and Camunda 8](docs/camunda.md#where-the-checks-come-from)). Both are pinned exactly: they decide which findings an agent sees, the tests pin those findings, and bpmn-cli uses bpmnlint's `Linter` beyond its documented API. `camunda-bpmn-moddle` (pinned to `8.0.1`) is a development dependency: its Camunda 7 descriptor diff --git a/docs/bpmnlint.md b/docs/bpmnlint.md index 3034bf89..fe213c37 100644 --- a/docs/bpmnlint.md +++ b/docs/bpmnlint.md @@ -52,6 +52,11 @@ and `@miragon/rules` come with bpmn-cli and need no install. Keep the `moddleExtensions` entry. Without it camunda-compat sees no `camunda:` or `zeebe:` properties and reports them missing, as bpmnlint does in CI. +bpmn-cli runs camunda-compat and the Miragon engine layer for every Camunda 7 / 8 file anyway, as +its `W_C7_*` / `W_C8_*` findings ([Camunda 7 and Camunda 8](camunda.md#where-the-checks-come-from)). +Extend them in the `.bpmnlintrc` when CI runs `npx bpmnlint` too: the same findings are then listed +twice, once under their code and once under the rule name. + ::: warning Untrusted repositories A `.bpmnlintrc` can name plugins of the repository, and `bpmn validate` then runs their code, as `npx bpmnlint` would. In a repository you do not trust, pass `--no-lint-config`. See diff --git a/docs/camunda.md b/docs/camunda.md index 48513f03..2589eabe 100644 --- a/docs/camunda.md +++ b/docs/camunda.md @@ -130,16 +130,151 @@ with the fix: $ bpmn set invoice.bpmn flow_amountOver1000ToApproveInvoice 'condition=${amount > 1000}' ... $ bpmn validate invoice.bpmn --strict -W_C8_DEPLOY_EXPRESSION flow_amountOver1000ToApproveInvoice: The condition "${amount > 1000}" of sequence flow flow_amountOver1000ToApproveInvoice is no FEEL expression; Camunda 8 needs one starting with = and refuses the file (`bpmn set flow_amountOver1000ToApproveInvoice 'condition== amount > 1000'` (check the FEEL syntax: and / or, = for equality).) +W_C8_DEPLOY_EXPRESSION flow_amountOver1000ToApproveInvoice: The condition "${amount > 1000}" of sequence flow flow_amountOver1000ToApproveInvoice is no FEEL expression; Camunda 8 needs one starting with = and refuses the file (`bpmn set flow_amountOver1000ToApproveInvoice condition==`.) platform: c8 (modeler:executionPlatform "Camunda Cloud") - 1 refused at deploy, 0 runtime, 0 practice finding(s) layout: ok valid, 1 warning(s) ``` +## Where the checks come from + +The `W_C7_*` / `W_C8_*` findings come from bpmnlint rules, loaded the first time a file of that +engine is checked, through the engine entry points of `@miragon/bpmnlint-plugin-rules` +(`camunda7` / `camunda8`): + +- camunda-compat's config for the file's `modeler:executionPlatformVersion` (the latest without + one; `--json` platform names the version); +- bpmn-cli's own rules for what camunda-compat leaves out (`bpmn-cli/c7-*`, `bpmn-cli/c8-*` in + `src/adapters/out/bpmnlint/engine/rules/`), written on the plugin's `EngineRule` and handed to it + with `withRules`. They give their hint with the finding; +- a few checks that need the file's text or content the engine's parser drops (`bpmn-cli` in the + tables). + +bpmn-cli maps each rule to its code. The hint of a camunda-compat finding is built from where it +points; its message is camunda-compat's. A rule's severity says what the finding means: `error` is +deploy, `warn` runtime, `info` practice. + +Where camunda-compat reads a file differently than Camunda 8.9 (engine-checked), bpmn-cli drops its +finding and follows the engine: white space only is a value for a job type, a message, signal, +process or decision name, a result variable, a listener type, an error or escalation code and a link +name; timer values are read as Camunda 8 parses them (`bpmn-cli/c8-timer-value`); a +`zeebe:jobPriorityDefinition` deploys on 8.9; a condition on a flow out of a gateway is the +structural `W_CONDITION_IGNORED`; of two none start events or two link catch events with one name, +the second is reported; an element with a doubled extension element reports the doubling only. + +Where camunda-compat is stricter than Camunda 8.9, bpmn-cli follows camunda-compat: + +- a flow without a condition next to conditional ones out of an exclusive or inclusive gateway + without a default is refused at deploy (`W_C8_EXCLUSIVE_GATEWAY`, reported on the flow), as is a + condition on a flow from an element that is no gateway (`W_C8_CONDITION_IGNORED`); +- an error or escalation that a catch event references needs its code (`W_C8_DEPLOY_ERROR`, + `W_C8_DEPLOY_ESCALATION`); +- execution listeners `beforeAll` and `cancel` are accepted from 8.10 on; +- an intermediate catch or boundary event without an event definition is an unsupported element + (`W_C8_DEPLOY_UNSUPPORTED`); +- a target an event-based gateway may not lead to is reported on the target, not the gateway + (`W_C8_DEPLOY_EVENT_GATEWAY`). + +bpmn-cli switches camunda-compat's `user-task-definition` off (a user task runs without a form), +reports `zeebe-user-task` as practice and checks FEEL with its own rule (`bpmn-cli/c8-feel-expression`, +camunda-compat's `feel` is off). + +
+Camunda 7: code and rules + +| code | rules | +| --- | --- | +| `W_C7_BAD_VALUE` | `bpmn-cli/c7-bad-value`, bpmn-cli (non-exact booleans) | +| `W_C7_DANGLING_REF` | `bpmn-cli/c7-dangling-reference` | +| `W_C7_DEPLOY_AD_HOC_SUBPROCESS` | `bpmn-cli/c7-ad-hoc-sub-process` | +| `W_C7_DEPLOY_BAD_VALUE` | `bpmn-cli/c7-attribute-value` | +| `W_C7_DEPLOY_BINDING_VERSION` | `bpmn-cli/c7-binding-version` | +| `W_C7_DEPLOY_BOUNDARY_HOST` | `bpmn-cli/c7-boundary-host` | +| `W_C7_DEPLOY_CALLED_ELEMENT` | `bpmn-cli/c7-called-element` | +| `W_C7_DEPLOY_COMPLEX_GATEWAY` | `bpmn-cli/c7-complex-gateway` | +| `W_C7_DEPLOY_DEFINITIONS_EXTENSION` | `bpmn-cli/c7-definitions-extension` | +| `W_C7_DEPLOY_DUPLICATE_EXTENSION` | `bpmn-cli/c7-duplicate-extension` | +| `W_C7_DEPLOY_ERROR` | `bpmn-cli/c7-error` | +| `W_C7_DEPLOY_ESCALATION` | `bpmn-cli/c7-escalation` | +| `W_C7_DEPLOY_EVENT_DEFINITION` | `bpmn-cli/c7-event-definition` | +| `W_C7_DEPLOY_EVENT_GATEWAY` | `bpmn-cli/c7-event-gateway` | +| `W_C7_DEPLOY_EXCLUSIVE_GATEWAY` | `bpmn-cli/c7-exclusive-gateway` | +| `W_C7_DEPLOY_EXTENSION` | `bpmn-cli/c7-extension` | +| `W_C7_DEPLOY_EXTERNAL_TOPIC` | `bpmn-cli/c7-external-topic` | +| `W_C7_DEPLOY_FORM` | `bpmn-cli/c7-form` | +| `W_C7_DEPLOY_HISTORY_TTL` | `camunda-compat/history-time-to-live`, `bpmn-cli/c7-history-time-to-live` | +| `W_C7_DEPLOY_IMPLEMENTATION` | `bpmn-cli/c7-implementation` | +| `W_C7_DEPLOY_INPUT_OUTPUT` | `bpmn-cli/c7-input-output` | +| `W_C7_DEPLOY_LINK` | `bpmn-cli/c7-link-event` | +| `W_C7_DEPLOY_LISTENER` | `bpmn-cli/c7-listener` | +| `W_C7_DEPLOY_MESSAGE` | `bpmn-cli/c7-message` | +| `W_C7_DEPLOY_MULTI_INSTANCE` | `bpmn-cli/c7-multi-instance` | +| `W_C7_DEPLOY_RESULT_VARIABLE` | `bpmn-cli/c7-result-variable` | +| `W_C7_DEPLOY_SCHEMA` | bpmn-cli (unprefixed attributes) | +| `W_C7_DEPLOY_SCRIPT` | `bpmn-cli/c7-script` | +| `W_C7_DEPLOY_SIGNAL` | `bpmn-cli/c7-signal` | +| `W_C7_DEPLOY_START_EVENT` | `bpmn-cli/c7-start-event` | +| `W_C7_DUPLICATE_EXTENSION` | `bpmn-cli/c7-async-duplicate-extension` | +| `W_C7_EMPTY_CONDITION` | `bpmn-cli/c7-empty-condition` | +| `W_C7_EXCLUSIVE_GATEWAY_DEFAULT` | `bpmn-cli/c7-implicit-default-flow` | +| `W_C7_FOREIGN_CONTENT` | `bpmn-cli/c7-foreign-content` | +| `W_C7_MESSAGE_NO_IMPLEMENTATION` | `bpmn-cli/c7-message-implementation` | +| `W_C7_MISPLACED_ATTRIBUTE` | `bpmn-cli/c7-misplaced-attribute` | +| `W_C7_MISPLACED_EXTENSION` | `bpmn-cli/c7-misplaced-extension` | +| `W_C7_MULTIPLE_EVENT_DEFINITIONS` | `bpmn-cli/c7-multiple-event-definitions` | +| `W_C7_TRANSACTION_NO_START` | `bpmn-cli/c7-transaction-start` | +| `W_C7_UNKNOWN_ATTRIBUTE` | `bpmn-cli/c7-unknown-attribute` | +| `W_C7_UNKNOWN_ELEMENT` | bpmn-cli | + +
+ +
+Camunda 8: code and rules + +| code | rules | +| --- | --- | +| `W_C8_BAD_VALUE` | `bpmn-cli/c8-bad-value` | +| `W_C8_CONDITION_IGNORED` | `camunda-compat/sequence-flow-condition` | +| `W_C8_DEPLOY_AD_HOC_SUBPROCESS` | `camunda-compat/ad-hoc-sub-process`, `bpmn-cli/c8-ad-hoc-sub-process` | +| `W_C8_DEPLOY_BOUNDARY_HOST` | `bpmn-cli/c8-boundary-host` | +| `W_C8_DEPLOY_CALLED_ELEMENT` | `camunda-compat/called-element`, `camunda-compat/no-business-id`, `camunda-compat/no-propagate-all-parent-variables` | +| `W_C8_DEPLOY_DUPLICATE_EXTENSION` | `bpmn-cli/c8-duplicate-extension` | +| `W_C8_DEPLOY_ERROR` | `camunda-compat/error-reference`, `bpmn-cli/c8-error` | +| `W_C8_DEPLOY_ESCALATION` | `camunda-compat/escalation-boundary-event-attached-to-ref`, `camunda-compat/escalation-reference` | +| `W_C8_DEPLOY_EVENT_DEFINITION` | `camunda-compat/wait-for-completion`, `bpmn-cli/c8-event-definition` | +| `W_C8_DEPLOY_EVENT_GATEWAY` | `camunda-compat/event-based-gateway-target`, `bpmn-cli/c8-event-gateway` | +| `W_C8_DEPLOY_EXECUTABLE` | `camunda-compat/executable-process` | +| `W_C8_DEPLOY_EXPRESSION` | `camunda-compat/feel`, `camunda-compat/feel-compatibility`, `camunda-compat/no-expression`, `bpmn-cli/c8-feel-expression` | +| `W_C8_DEPLOY_EXTENSION` | `camunda-compat/agent-fromai-contract`, `camunda-compat/agent-tool-documentation`, `camunda-compat/agent-tool-output-key`, `camunda-compat/connector-properties`, `camunda-compat/duplicate-task-headers`, `camunda-compat/io-mapping`, `camunda-compat/no-binding-type`, `camunda-compat/no-job-priority-definition`, `camunda-compat/no-version-tag`, `camunda-compat/no-zeebe-properties`, `camunda-compat/secrets`, `camunda-compat/start-event-form`, `camunda-compat/start-event-form-embedded`, `camunda-compat/unresolvable-secret-reference`, `camunda-compat/variable-name`, `camunda-compat/version-tag`, `bpmn-cli/c8-extension` | +| `W_C8_DEPLOY_IMPLEMENTATION` | `camunda-compat/implementation`, `bpmn-cli/c8-implementation`, bpmn-cli (`zeebe:publishMessage`) | +| `W_C8_DEPLOY_LINK` | `camunda-compat/link-event`, `bpmn-cli/c8-link-event` | +| `W_C8_DEPLOY_LISTENER` | `camunda-compat/before-all-execution-listener`, `camunda-compat/cancel-execution-listener`, `camunda-compat/duplicate-execution-listener-headers`, `camunda-compat/duplicate-execution-listeners`, `camunda-compat/execution-listener`, `camunda-compat/no-before-all-execution-listener`, `camunda-compat/no-cancel-execution-listener`, `camunda-compat/no-execution-listener-headers`, `camunda-compat/no-execution-listeners`, `camunda-compat/no-task-listeners`, `camunda-compat/task-listener`, `bpmn-cli/c8-listener` | +| `W_C8_DEPLOY_MESSAGE` | `camunda-compat/message-reference`, `camunda-compat/subscription`, `bpmn-cli/c8-message`, bpmn-cli (`zeebe:publishMessage`) | +| `W_C8_DEPLOY_MULTI_INSTANCE` | `camunda-compat/loop-characteristics` | +| `W_C8_DEPLOY_SCHEMA` | `bpmn-cli/c8-schema`, bpmn-cli (unprefixed attributes; the file text: child order, IDREFs, booleans, unreadable ids) | +| `W_C8_DEPLOY_SIGNAL` | `camunda-compat/signal-reference`, `bpmn-cli/c8-signal` | +| `W_C8_DEPLOY_START_EVENT` | `start-event-required`, `camunda-compat/no-multiple-none-start-events`, `bpmn-cli/c8-start-event` | +| `W_C8_DEPLOY_TIMER` | `camunda-compat/timer` | +| `W_C8_DEPLOY_UNSUPPORTED` | `camunda-compat/collapsed-subprocess`, `camunda-compat/element-type`, `camunda-compat/inclusive-gateway`, `camunda-compat/no-interrupting-event-subprocess`, `camunda-compat/no-loop`, `camunda-compat/no-signal-event-sub-process` | +| `W_C8_DEPLOY_USER_TASK` | `camunda-compat/no-candidate-users`, `camunda-compat/no-priority-definition`, `camunda-compat/no-task-schedule`, `camunda-compat/no-zeebe-user-task`, `camunda-compat/priority-definition`, `camunda-compat/task-schedule`, `camunda-compat/user-task-definition`, `camunda-compat/user-task-form` | +| `W_C8_EXCLUSIVE_GATEWAY` | `camunda-compat/sequence-flow-condition` | +| `W_C8_EXPRESSION` | `bpmn-cli/c8-feel-completion-condition` | +| `W_C8_FOREIGN_CONTENT` | `bpmn-cli/c8-foreign-content` | +| `W_C8_JOB_WORKER_USER_TASK` | `camunda-compat/zeebe-user-task` | +| `W_C8_MISPLACED_ATTRIBUTE` | `bpmn-cli/c8-misplaced-attribute` | +| `W_C8_MISPLACED_EXTENSION` | `bpmn-cli/c8-misplaced-extension` | +| `W_C8_STANDARD_LOOP` | `camunda-compat/loop-characteristics` | +| `W_C8_UNKNOWN_ATTRIBUTE` | `bpmn-cli/c8-unknown-attribute` | +| `W_C8_UNKNOWN_ELEMENT` | bpmn-cli | +| `W_C8_UNSUPPORTED_IMPLEMENTATION` | bpmn-cli (`zeebe:publishMessage`) | + +
+ ## bpmnlint and camunda-compat -camunda-compat comes with bpmn-cli and runs from the project's `.bpmnlintrc`. Its findings overlap -with the `W_C7_*` / `W_C8_*` rules; both are listed. Setup: [bpmnlint](bpmnlint.md). +A project's `.bpmnlintrc` runs on top, as an advisory validator ([bpmnlint](bpmnlint.md)). When it +extends camunda-compat or the Miragon engine layers too, their findings are listed twice: once +under their bpmn-cli code, once under the rule name of the `.bpmnlintrc`. ## Next steps diff --git a/docs/library.md b/docs/library.md index cc860df4..cc60e641 100644 --- a/docs/library.md +++ b/docs/library.md @@ -120,14 +120,14 @@ validator like the CLI ([bpmnlint](bpmnlint.md)). Plugins the project installed A host that keeps a parsed model between calls uses the pipeline on a `Doc`: `Doc.fromXml(xml)`, then `mutateDoc(doc, ops, opts)`, `layoutDoc`, `checkDoc`. They never write. -Every other part (views, metrics, the diagram commands, the platform profile) is exported too: +Every other part (views, metrics, the diagram commands, the platform detection) is exported too: `runOps(doc, commands)` runs commands on a `Doc`, `parseOps(json)` reads the ops JSON into `EditCommand`s. -::: warning Load the profile rules before runProfile -`runProfile` and `validateDoc(doc, { platform })` are synchronous: call `await loadProfiles()` -once before them, otherwise they throw `E_PROFILE_NOT_LOADED` on a Camunda 7 / 8 document. -`mutateDoc`, `checkDoc` and the in-memory API load the rules themselves. +::: tip The Camunda 7 / 8 rules run in the async calls +`validateDoc(doc)` is the structural validation and lint alone. The engine rules (`W_C7_*` / +`W_C8_*`) are bpmnlint rules that load on demand; `checkDoc`, `mutateDoc` and the in-memory API run +them. ::: ### bpmnlint as a validator @@ -191,17 +191,24 @@ await applyToXml(xml, ops, { ## Browser bundles -The core runs in a browser; CI fails on anything Node-only. The Camunda 7 and 8 rules are -chunks of their own, fetched the first time a document of that platform is validated -(`loadProfile('c7')` prefetches). PNG rendering and bpmnlint live in `@miragon/bpmn-cli/node` -only. +The core runs in a browser; CI fails on anything Node-only. The Camunda 7 and 8 rules (bpmnlint, +camunda-compat and the Miragon engine layer) are chunks of their own, fetched the first time a +document of that platform is validated. PNG rendering and the project's `.bpmnlintrc` live in +`@miragon/bpmn-cli/node` only. | bundle (minified / gzip) | size | | --- | ---: | -| everything `@miragon/bpmn-cli` exports | 1103.3 / 346.7 KB | -| `applyToXml` only | 799.8 / 250.3 KB | -| Camunda 7 rules, on demand | 64.4 / 18.8 KB | -| Camunda 8 rules, on demand | 88.3 / 25.2 KB | +| everything `@miragon/bpmn-cli` exports | 1087.5 / 341.7 KB | +| `applyToXml` only | 788.0 / 246.4 KB | +| Camunda 7 rules, on demand | 111.4 / 28.4 KB | +| Camunda 8 rules, on demand | 350.3 / 95.3 KB | +| engine run (bpmnlint and the mapping to `W_C7_*` / `W_C8_*`), on demand | 39.3 / 12.0 KB | + +::: warning No Camunda 7 / 8 call at the top level of the entry module +The rule chunks share packages with the core (min-dash), which a bundler puts into the entry chunk: +a rule chunk then imports the entry chunk. An entry module that awaits a Camunda 7 / 8 call at its +top level waits for itself. Make the first call from a function the entry module calls. +::: `node tools/iso/check.mjs --table` prints the current sizes. diff --git a/docs/testing.md b/docs/testing.md index ebf1a0e1..64a1fc98 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -380,14 +380,16 @@ 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). - 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 + and the Camunda 8 entry of `@miragon/bpmnlint-plugin-rules`, and bpmn-cli's + engine run in `src/adapters/out/bpmnlint/engine/` with bpmnlint's linter). + It fails when a rule module is in the initial chunks of + either entry, naming it; when a module of the engine run imports a module the entry loads, naming both (Rollup / Vite would put that module into the host's entry chunk and make the rule chunk import the entry chunk back: a host whose entry module awaits a Camunda 7 / 8 call at top level would hang; - the rules get the core helpers from `src/domain/validation/profiles/rule-kit.ts`); and + the engine run gets the core helpers from `src/adapters/out/bpmnlint/engine-kit.ts`; + the plugin's packages share min-dash with the core, so a host must not await + such a call at top level, [library.md](library.md#browser-bundles)); and when a size is over its budget in `tools/iso/budget.json` (KB = 1024 bytes; set to the measured size plus about 2 %: raise one only in the change that needs it, with the reason in @@ -401,29 +403,28 @@ npx vitest run test/e2e/isomorphic.test.ts `process` read, and runs the bundle in a `vm` context without any Node global: `applyToXml` (also with host validators, and a no-op that must come back `unchanged`), `layoutXml` (both engines), `newXml`, - `validateXml` with the Camunda 7 and the Camunda 8 profile and with host + `validateXml` with the Camunda 7 and the Camunda 8 rules and with host validators, `showXml`, `findXml` and `metricsXml` must give there exactly what they give in Node, and `renderSvg` the same SVG. It also checks that the inlined Camunda 7 descriptor (`src/domain/document/vendor/camunda-descriptor.ts`) equals the installed `camunda-bpmn-moddle`; regenerate it with `node tools/gen-camunda-descriptor.mjs`. - The same file bundles `src/index.ts` as an ESM split bundle (what Vite or - esbuild make of the `import()` in `src/domain/validation/profiles/PlatformProfiles.ts`), writes a + esbuild make of the `import()` in `src/adapters/out/bpmnlint/BpmnlintEngineLint.ts`), writes a fresh copy per case, loads it with Node's own loader and records which chunk is evaluated: a plain model (also with a host validator, whose error, pre-existing error and covered lint warning come out like Node's) and `platform: 'none'` load no profile chunk, a Camunda 7 file only the Camunda 7 rules, a Camunda 8 file only the Camunda 8 rules; a batch that turns a plain file into a Camunda 7 / 8 file loads them after the ops and reports the added - findings of 0.4.0; `runProfile` without loaded rules throws - `E_PROFILE_NOT_LOADED`; a missing chunk rejects with `E_PROFILE_LOAD` + findings; `validateDoc` loads no rule chunk; a missing chunk rejects with `E_PROFILE_LOAD` (its hint says a browser may keep the failed fetch until the page reloads) and a later call imports it again, which in Node loads it. It also checks that the bundle's entry chunk is the requested one and that the rule modules (and so their chunks) import nothing the entry loads. -- Test files that call `runProfile` or `validateDoc(doc, { platform })` - directly load the rules first (`await loadProfiles()` at the top); the - pipeline tests do not, so they run the on-demand path. +- Tests that need the engine findings of a document on their own use + `runProfile` of `test/helpers/engine.ts`; the pipeline tests run the + on-demand path. - `test/e2e/api.test.ts` checks that the in-memory API gives what the CLI writes and prints for the same input. @@ -638,32 +639,23 @@ flow between two lanes). ## Engine checks (Camunda 7) -The Camunda 7 profile of `bpmn validate` (`src/domain/validation/profiles/C7Profile.ts`) states for -every rule what the engines do. `test/domain/validation/c7-profile.test.ts` holds one synthetic -model per rule (82 models) with the expected codes and the engine verdict; -by default it checks only the profile. To re-check the verdicts against live -engines (REST, no authentication), list their REST roots: +The Camunda 7 rules of `bpmn validate` are bpmnlint rules: camunda-compat's `history-time-to-live` +and the `c7-*` rules of `@miragon/bpmnlint-plugin-rules`, whose specs hold one model per rule with +the engines' verdict ([Camunda 7 and Camunda 8](camunda.md#where-the-checks-come-from)). bpmn-cli +tests what it adds: the code and the hint of each finding (`src/adapters/out/bpmnlint/engine/`), +and the checks it makes itself. `test/adapters/out/bpmnlint/engine-codes.test.ts` fails when a rule +of a camunda-compat version or of the Miragon engine layer has no code. +`test/e2e/camunda7-rules.test.ts` holds synthetic models with the engines' verdict (events with +several event definitions, attributes BPMN does not define) and runs their hints through the CLI. To +re-check the verdicts against live engines (REST, no authentication), list their REST roots: ``` -BPMN_C7_ENGINES=http://localhost:8080/engine-rest[,http://host:port/engine-rest ...] npx vitest run test/domain/validation/c7-profile.test.ts +BPMN_C7_ENGINES=http://localhost:8080/engine-rest[,http://host:port/engine-rest ...] npx vitest run test/e2e/camunda7-rules.test.ts ``` -Each model is deployed to every listed engine, the test asserts the engine's -verdict and that a deploy-severity finding (`W_C7_DEPLOY_*`) is reported -exactly for the refused ones, and every deployment is deleted again. The -verdicts were identical on Camunda 7.24.0, CIB seven 2.2.0 and Operaton 2.1.5 -(341 tests with three engines). The follow-up rounds add their own models with -engine verdicts the same way: `test/domain/validation/c7-profile-rules.test.ts` (deploy -rules the engines disagreed with or the profile missed; `operaton` marks a -verdict only Operaton gives, the engine is told apart by -`/telemetry/data`), `test/e2e/camunda7-rules.test.ts` (events with -several event definitions, attributes BPMN does not define) and -`test/domain/validation/c7-schema-rules.test.ts` (schema rules on every event definition, -start events of transactions / empty sub-processes / ad-hoc sub-processes; -`run` also starts the process and checks that it fails or runs, and the -second-start hint is followed and run). Run all four with the same -`BPMN_C7_ENGINES`; `--no-file-parallelism` keeps two files from deploying -one message name at the same time. +Each model is deployed to every listed engine, the test asserts the engine's verdict and that a +deploy-severity finding (`W_C7_DEPLOY_*`) is reported exactly for the refused ones, and every +deployment is deleted again. The other C7 regression tests (`test/domain/editing/c7-semantic.test.ts`, `test/domain/editing/c7-ext-structure.test.ts`, `test/e2e/camunda7.test.ts`, @@ -677,27 +669,19 @@ element by element. ## Engine checks (Camunda 8) -The Camunda 8 profile (`src/domain/validation/profiles/C8Profile.ts`) states for every rule what -Camunda 8.9 does; placement and known names come from the Zeebe descriptor +The Camunda 8 rules are camunda-compat's config for the file's version and the `c8-*` rules of +`@miragon/bpmnlint-plugin-rules` (the FEEL check with its 762 engine verdicts lives there). bpmn-cli +makes the checks a typed parse cannot see itself: unknown zeebe elements, unprefixed attributes, +`zeebe:publishMessage` and the schema rules of the file's text (child order, IDREFs, booleans); +placement and known names come from the Zeebe descriptor (`src/domain/document/vendor/ZeebeDescriptor.ts`, the inlined `zeebe-bpmn-moddle`; regenerate with -`node tools/gen-camunda-descriptor.mjs`, `test/e2e/isomorphic.test.ts` fails -while it is stale). `test/domain/validation/c8-profile.test.ts` holds one synthetic model per -rule (151 models) with the expected codes and Camunda 8's verdict, the timer -and FEEL value checks with the values they were checked on, and the -`validate` output; `test/domain/editing/c8-ops.test.ts` covers `set` / `ext` / `retype` / -`show` in Camunda 8 files and runs the profile's hints through the real CLI -until a refused model has no deploy finding left; `test/domain/validation/c8-feel-and-deploy.test.ts` -checks the FEEL syntax check against `test/fixtures/c8/feel-verdicts.json` -(762 synthetic expressions with the verdict of Camunda 8.9.22, one per line: -add a new expression with the verdict you got from the engine) and the -schema-text, white-space, `refAs` and subscription rules; -`test/domain/validation/c8-values.test.ts` holds the timer range, cron field and -xsd:boolean models (27, with the verdict) and the duplicate id. By default they -check the CLI only. To re-check against a live Camunda 8 (REST v2, no -authentication), name its v2 root: +`node tools/gen-camunda-descriptor.mjs`, `test/e2e/isomorphic.test.ts` fails while it is stale). +`test/domain/editing/c8-ops.test.ts` covers `set` / `ext` / `retype` / `show` in Camunda 8 files and +runs the hints through the real CLI until a refused model has no deploy finding left. To re-check +against a live Camunda 8 (REST v2, no authentication), name its v2 root: ``` -BPMN_C8_ENGINE=http://localhost:8088/v2 npx vitest run test/domain/validation/c8-profile.test.ts test/domain/editing/c8-ops.test.ts test/domain/validation/c8-feel-and-deploy.test.ts test/domain/validation/c8-values.test.ts +BPMN_C8_ENGINE=http://localhost:8088/v2 npx vitest run test/domain/editing/c8-ops.test.ts ``` Each model is deployed (`POST /v2/deployments`), the test asserts that the diff --git a/src/adapters/in/report/command-report.ts b/src/adapters/in/report/command-report.ts index a18efd83..a97fafc7 100644 --- a/src/adapters/in/report/command-report.ts +++ b/src/adapters/in/report/command-report.ts @@ -7,7 +7,7 @@ import type { DiagramCommandResult } from '../../../domain/diagram/commands/Diag import type { Finding } from '../../../domain/document/Finding.js'; import type { CheckResult } from '../../../application/port/in/ValidateDocumentUseCase.js'; import type { LayoutStatus } from '../../../domain/diagram/types.js'; -import type { PlatformSummary } from '../../../domain/validation/profiles/PlatformProfiles.js'; +import type { PlatformSummary } from '../../../domain/validation/PlatformCheck.js'; import type { Change } from '../../../domain/document/ChangeSet.js'; import type { ValidationResult } from '../../../domain/validation/StructuralValidator.js'; import type { ValidatorReport } from '../../../domain/validation/validators.js'; diff --git a/src/adapters/in/report/guide.ts b/src/adapters/in/report/guide.ts index 39c35c66..5c5764d1 100644 --- a/src/adapters/in/report/guide.ts +++ b/src/adapters/in/report/guide.ts @@ -27,8 +27,7 @@ const USAGE_AND_IO_ERRORS: ErrorDoc[] = [ { code: 'E_FILE_EXISTS', meaning: '`bpmn new` refuses to overwrite an existing file.', fix: 'Choose another name, or pass --force to overwrite.' }, { code: 'E_INVALID_XML', meaning: 'The --xml snippet of `ext add` could not be parsed.', fix: 'Pass one well-formed element with declared prefixes, e.g. --xml \'\', or use attr=value instead.' }, { code: 'E_RENDER', meaning: '`bpmn render` (renderPng) could not rasterise the image: the PNG rasteriser (@resvg/resvg-wasm) failed to load or failed on this drawing (exit 4; never a process abort); or nothing on the plane can be drawn because every shape and connection has broken coordinates (not a number, beyond ±1,000,000 px, a negative size).', fix: 'Write the SVG instead (`--format svg`), or render a smaller part; report the file if it keeps failing. Broken coordinates: `bpmn layout ` draws the model again.' }, - { code: 'E_PROFILE_LOAD', meaning: 'Library use (exit 4): the Camunda 7 or Camunda 8 profile rules, which load on demand (a chunk of their own in a browser bundle), could not be loaded; the call rejected before a result existed and nothing was written. `details.platform` names the platform.', fix: 'Check that the chunk is served next to the entry chunk (base URL, network, content security policy). The library does not cache the failure: a later call imports the chunk again, which loads it in Node; a browser may keep the failed module fetch until the page reloads (Chrome does), so reload the page there after fixing the cause. Prefetch with `loadProfile(platform)` when a file is opened to find out early.' }, - { code: 'E_PROFILE_NOT_LOADED', meaning: 'Library use (exit 1): `runProfile` or `validateDoc(doc, { platform })` ran on a Camunda 7 / 8 document whose profile rules are not loaded yet (they load on demand; the async API, `applyToXml`, `validateXml`, `mutateDoc`, `checkDoc`, loads them itself). `details.platform` names the platform.', fix: '`await loadProfile(platform)` (or `await loadProfiles()` for both) once before the synchronous calls.' }, + { code: 'E_PROFILE_LOAD', meaning: 'Library use (exit 4): the Camunda 7 or Camunda 8 engine rules (bpmnlint with camunda-compat and the Miragon engine layer), which load on demand (a chunk of their own in a browser bundle), could not be loaded; the call rejected before a result existed and nothing was written. `details.platform` names the platform.', fix: 'Check that the chunk is served next to the entry chunk (base URL, network, content security policy). The library does not cache the failure: a later call imports the chunk again, which loads it in Node; a browser may keep the failed module fetch until the page reloads (Chrome does), so reload the page there after fixing the cause.' }, ]; /** Codes of ids and their lookup. */ @@ -170,9 +169,10 @@ const VENDOR_CONTENT_ERRORS: ErrorDoc[] = [ { code: 'W_PROPERTY_INAPPLICABLE', meaning: '`retype`: camunda attributes or extension elements of the element are not allowed on the new kind by the Camunda descriptor (camunda:assignee on a serviceTask, camunda:topic on a userTask, camunda:taskListener outside a userTask). They are kept (nothing was dropped) but have no effect.', fix: 'Remove them with the commands in the hint (`bpmn set camunda:assignee=`, `bpmn ext remove camunda:taskListener`), set what the new kind needs, or retype back.' }, ]; -/** Platform profile findings, from validate and the ones a mutation introduces. */ +/** Camunda 7 / 8 findings, from validate and the ones a mutation introduces. */ const PLATFORM_PROFILE_FINDINGS: ErrorDoc[] = [ - { code: 'W_C7_*', meaning: 'A finding of the Camunda 7 profile, which runs in Camunda 7 files (detected from modeler:executionPlatform or the camunda namespace; CIB seven and Operaton included; `validate --platform c7|c8|none|auto`). A file that uses Operaton\'s own namespace (operaton:*) is read the way Operaton reads it: operaton:* first, camunda:* as the fallback (Camunda 7 and CIB seven ignore operaton:*); `--json` platform says `"operaton": true` and the detail line says so. Every finding has a severity (JSON field `severity`): deploy = the engines (Camunda 7.24, CIB seven 2.2, Operaton 2.1) refuse the file (codes W_C7_DEPLOY_*); runtime = it deploys but the setting is ignored or fails when it runs; practice = it works, the engine logs a warning. Only executable processes are checked, without the content of ad-hoc sub-processes (the engines skip both); the schema rules cover the whole file (W_C7_DEPLOY_SCHEMA, and the event definitions of W_C7_DEPLOY_EVENT_DEFINITION / W_C7_DEPLOY_LINK). A mutation reports only the findings it introduced.', fix: 'Fix every W_C7_DEPLOY_* finding before deploying; the hint of each finding names the exact `bpmn set` / `bpmn ext` / `bpmn add|remove|move` command. `bpmn validate ` lists all of them.' }, + { code: 'W_C7_*', meaning: 'A finding of the Camunda 7 rules (camunda-compat for the file\'s modeler:executionPlatformVersion, else the latest, and the c7-* rules of @miragon/bpmnlint-plugin-rules, mapped to these codes), which run in Camunda 7 files (detected from modeler:executionPlatform or the camunda namespace; CIB seven and Operaton included; `validate --platform c7|c8|none|auto`). A file that uses Operaton\'s own namespace (operaton:*) is read the way Operaton reads it: operaton:* first, camunda:* as the fallback (Camunda 7 and CIB seven ignore operaton:*); `--json` platform says `"operaton": true` and the detail line says so. Every finding has a severity (JSON field `severity`): deploy = the engines (Camunda 7.24, CIB seven 2.2, Operaton 2.1) refuse the file (codes W_C7_DEPLOY_*); runtime = it deploys but the setting is ignored or fails when it runs; practice = it works, the engine logs a warning. Only executable processes are checked, without the content of ad-hoc sub-processes (the engines skip both); the schema rules cover the whole file (W_C7_DEPLOY_SCHEMA, and the event definitions of W_C7_DEPLOY_EVENT_DEFINITION / W_C7_DEPLOY_LINK). A mutation reports only the findings it introduced.', fix: 'Fix every W_C7_DEPLOY_* finding before deploying; the hint of each finding names the exact `bpmn set` / `bpmn ext` / `bpmn add|remove|move` command. `bpmn validate ` lists all of them.' }, + { code: 'W_C7_ENGINE_RULE', meaning: 'A finding of a Camunda 7 engine rule bpmn-cli has no code for yet (a rule a newer camunda-compat adds); the message names what the engine refuses or ignores.', fix: 'Fix what the message names; report the rule so it gets its own code.' }, { code: 'W_C7_DEPLOY_HISTORY_TTL', meaning: 'An executable process has no camunda:historyTimeToLive, or a value that is not a number of days or PD; Camunda 7.20+, CIB seven and Operaton refuse to deploy it (ENGINE-12018).', fix: '`bpmn set camunda:historyTimeToLive=180` (`bpmn new --target camunda7` writes 180).' }, { code: 'W_C7_DEPLOY_IMPLEMENTATION', meaning: 'A service, send or business rule task (or a message throw / end event definition) has no implementation (class, delegateExpression, expression, type=external or shell, connector; decisionRef for business rule tasks), camunda:type is neither external nor shell ("mail" included: the standard distributions cannot load it), or a shell task has no camunda:field command, an expression field, or wait / redirectError / cleanEnv other than true / false; the engines refuse the file.', fix: '`bpmn set camunda:type=external camunda:topic=` (or camunda:delegateExpression=${...}; `definition.camunda:...` for message events); a shell task: `bpmn ext add camunda:field name=command stringValue=`.' }, { code: 'W_C7_DEPLOY_EXTERNAL_TOPIC', meaning: 'camunda:type=external without camunda:topic; the engines refuse the file.', fix: '`bpmn set camunda:topic=`.' }, @@ -214,7 +214,8 @@ const PLATFORM_PROFILE_FINDINGS: ErrorDoc[] = [ { code: 'W_C7_DANGLING_REF', meaning: 'An id reference inside camunda content (camunda:errorEventDefinition errorRef) points to no element of the right type; the mapping is ignored at run time.', fix: 'Rebuild just that mapping (the hint has both commands; --replace would remove the other mappings): `bpmn ext remove \'camunda:errorEventDefinition[id=]\'`, then `bpmn ext add camunda:errorEventDefinition id= errorRef= expression=${...}`.' }, { code: 'W_C7_EXCLUSIVE_GATEWAY_DEFAULT', meaning: '(practice) An exclusive gateway has exactly one flow without condition and no default; the engine takes it as the default and logs a warning.', fix: '`bpmn set default=true`.' }, { code: 'W_C7_DUPLICATE_EXTENSION', meaning: '(practice) A second camunda:failedJobRetryTimeCycle on an element that is not asynchronous yet; the engines refuse the file once it is.', fix: 'Keep one: `bpmn ext add camunda:failedJobRetryTimeCycle --replace --body R3/PT5M`.' }, - { code: 'W_C8_*', meaning: 'A finding of the Camunda 8 profile, which runs in Camunda 8 files (modeler:executionPlatform "Camunda Cloud" or the zeebe namespace; `validate --platform c8` forces it). Every rule was checked against Camunda 8.9 (deploy and, for the runtime rules, running the process). Every finding has a severity (JSON field `severity`): deploy = Camunda 8 refuses the file (codes W_C8_DEPLOY_*); runtime = it deploys but the setting is ignored or fails when the process runs; practice = it works, but differently than intended. Executable processes are checked (Camunda 8 skips the others but refuses a file without any), the content of ad-hoc sub-processes included; the schema rules cover the whole file. Placement and known names come from the zeebe-bpmn-moddle descriptor. FEEL is parsed (the grammar Camunda 8.9 accepts; the JUEL habits &&, ||, ==, !, ${...}, single quotes are named). Timer values are checked for their form and range (a date that does not exist, 25:00, a cron field out of range), xsd:boolean attributes for true / false / 1 / 0. A mutation reports only the findings it introduced.', fix: 'Fix every W_C8_DEPLOY_* finding before deploying; the hint of each finding names the exact `bpmn ext` / `bpmn set` / `bpmn add|remove|retype` command. `bpmn validate ` lists all of them.' }, + { code: 'W_C8_*', meaning: 'A finding of the Camunda 8 rules (camunda-compat for the file\'s modeler:executionPlatformVersion, else the latest, and the c8-* rules of @miragon/bpmnlint-plugin-rules, mapped to these codes), which run in Camunda 8 files (modeler:executionPlatform "Camunda Cloud" or the zeebe namespace; `validate --platform c8` forces it). Where camunda-compat is stricter than Camunda 8.9 (a gateway flow without condition, an error code on a caught error), it decides. Every finding has a severity (JSON field `severity`): deploy = Camunda 8 refuses the file (codes W_C8_DEPLOY_*); runtime = it deploys but the setting is ignored or fails when the process runs; practice = it works, but differently than intended. Executable processes are checked (Camunda 8 skips the others but refuses a file without any), the content of ad-hoc sub-processes included; the schema rules cover the whole file. Placement and known names come from the zeebe-bpmn-moddle descriptor. FEEL is parsed (the grammar Camunda 8.9 accepts; the JUEL habits &&, ||, ==, !, ${...}, single quotes are named). Timer values are checked for their form and range (a date that does not exist, 25:00, a cron field out of range), xsd:boolean attributes for true / false / 1 / 0. A mutation reports only the findings it introduced.', fix: 'Fix every W_C8_DEPLOY_* finding before deploying; the hint of each finding names the exact `bpmn ext` / `bpmn set` / `bpmn add|remove|retype` command. `bpmn validate ` lists all of them.' }, + { code: 'W_C8_ENGINE_RULE', meaning: 'A finding of a Camunda 8 engine rule bpmn-cli has no code for yet (a rule a newer camunda-compat adds); the message names what the engine refuses or ignores.', fix: 'Fix what the message names; report the rule so it gets its own code.' }, { code: 'W_C8_DEPLOY_EXECUTABLE', meaning: 'The file has processes, but none is executable; Camunda 8 refuses it ("Must contain at least one executable process").', fix: '`bpmn set isExecutable=true`.' }, { code: 'W_C8_DEPLOY_IMPLEMENTATION', meaning: 'A service, send, script or business rule task, or a message throw / end event, has no implementation: Camunda 8 needs a zeebe:taskDefinition (the job type a worker subscribes to); a script task may have a zeebe:script (FEEL expression + resultVariable), a business rule task a zeebe:calledDecision (decisionId + resultVariable) instead, not both. Also: a job without type, a zeebe:script without expression or resultVariable, a zeebe:calledDecision without decisionId or resultVariable, a thrown message without name. Camunda 8 refuses the file.', fix: '`bpmn ext add zeebe:taskDefinition type=` (retries=3 optional); `bpmn ext add zeebe:script expression== resultVariable=`; `bpmn set calledDecision=` plus `bpmn ext add zeebe:calledDecision resultVariable=`.' }, { code: 'W_C8_DEPLOY_CALLED_ELEMENT', meaning: 'A call activity without zeebe:calledElement (the BPMN calledElement attribute is not read by Camunda 8), or one without processId; Camunda 8 refuses the file.', fix: '`bpmn ext add zeebe:calledElement processId= propagateAllChildVariables=false`.' }, diff --git a/src/adapters/out/bpmnlint/BpmnlintEngineLint.ts b/src/adapters/out/bpmnlint/BpmnlintEngineLint.ts new file mode 100644 index 00000000..18c1d97f --- /dev/null +++ b/src/adapters/out/bpmnlint/BpmnlintEngineLint.ts @@ -0,0 +1,56 @@ +import type { Doc } from '../../../domain/document/Doc.js'; +import type { ProfileFinding } from '../../../domain/document/Finding.js'; +import { ioError } from '../../../domain/document/utils/errors.js'; +import { firstLine } from '../../../domain/document/utils/text.js'; +import type { EnginePlatformInfo } from '../../../domain/validation/EngineLint.js'; +import type { EngineLintPort } from '../../../application/port/out/EngineLintPort.js'; +import type { BpmnlintLinterClass } from './bpmnlint-contract.js'; +import { ENGINE_KIT } from './engine-kit.js'; +import type { EngineRun } from './engine/EngineRun.js'; + +type Engine = EnginePlatformInfo['platform']; + +const ENGINE_NAMES: Record = { c7: 'Camunda 7', c8: 'Camunda 8' }; + +/** + * The engine's rules, bpmnlint's linter and the engine run: a chunk of their own + * per engine in a browser bundle, loaded the first time a document of that engine is checked. + */ +const LOADERS: Record Promise> = { + c7: async () => { + const [{ Camunda7Rules }, linter, { EngineRun }] = await Promise.all([import('./engine/rules/c7/index.js'), import('bpmnlint/lib/linter.js'), import('./engine/EngineRun.js')]); + return new EngineRun(ENGINE_KIT, 'c7', Camunda7Rules.lint(ENGINE_KIT), linter.default as unknown as BpmnlintLinterClass); + }, + c8: async () => { + const [{ Camunda8Rules }, linter, { EngineRun }] = await Promise.all([import('./engine/rules/c8/index.js'), import('bpmnlint/lib/linter.js'), import('./engine/EngineRun.js')]); + return new EngineRun(ENGINE_KIT, 'c8', Camunda8Rules.lint(ENGINE_KIT), linter.default as unknown as BpmnlintLinterClass); + }, +}; + +/** + * The Camunda 7 / 8 rules as bpmnlint runs them: camunda-compat (through @miragon/bpmnlint-plugin-rules) + * and bpmn-cli's own rules for what it leaves out, mapped to bpmn-cli's W_C7_* / W_C8_* findings. Each engine + * loads on first use; a failed load is forgotten, so a later call imports the chunk again (a + * browser may keep the failed fetch until the page reloads). + */ +export class BpmnlintEngineLint implements EngineLintPort { + private readonly loading: Partial>> = {}; + + async findings(doc: Doc, info: EnginePlatformInfo): Promise { + return (await this.load(info.platform)).findings(doc, info); + } + + private load(engine: Engine): Promise { + const loaded = this.loading[engine]; + if (loaded) return loaded; + const load = LOADERS[engine]().catch((err: unknown) => { + delete this.loading[engine]; + throw ioError('E_PROFILE_LOAD', `The ${ENGINE_NAMES[engine]} engine rules could not be loaded: ${firstLine(err)}`, { + platform: engine, + hint: 'They are a chunk of their own in a browser bundle: check that it is served next to the entry chunk (base URL, network, content security policy); nothing was written. A later call imports the chunk again (in Node that loads it), but a browser may keep the failed fetch until the page reloads (Chrome does): reload the page there.', + }); + }); + this.loading[engine] = load; + return load; + } +} diff --git a/src/adapters/out/bpmnlint/bpmnlint-contract.ts b/src/adapters/out/bpmnlint/bpmnlint-contract.ts index ca5cd8a9..0f19d0f2 100644 --- a/src/adapters/out/bpmnlint/bpmnlint-contract.ts +++ b/src/adapters/out/bpmnlint/bpmnlint-contract.ts @@ -20,6 +20,11 @@ export interface BpmnlintReport { category: string; } +/** An engine rule's report: camunda-compat's `path` and `data.type` say where and what. */ +export interface BpmnlintEngineReport extends BpmnlintReport { + data?: { type?: string; [key: string]: unknown }; +} + export interface BpmnlintRule { check?: unknown; meta?: unknown; diff --git a/src/adapters/out/bpmnlint/engine-kit.ts b/src/adapters/out/bpmnlint/engine-kit.ts new file mode 100644 index 00000000..855803cc --- /dev/null +++ b/src/adapters/out/bpmnlint/engine-kit.ts @@ -0,0 +1,47 @@ +import { BpmnModdle } from 'bpmn-moddle'; +import { kindLabel } from '../../../domain/document/Kinds.js'; +import { makeFinding } from '../../../domain/document/utils/finding.js'; +import { editDistance } from '../../../domain/document/utils/fuzzy.js'; +import { BPMN_NS, is, isBpmnElement, isDiElement, list, peek, walk } from '../../../domain/document/utils/moddle.js'; +import { decodeXml, elementChildren, readXmlText } from '../../../domain/document/utils/text-reader.js'; +import { CAMUNDA_DESCRIPTOR } from '../../../domain/document/vendor/camunda-descriptor.js'; +import { C7_URIS, CAMUNDA, CAMUNDA_URI, OPERATON_URI } from '../../../domain/document/vendor/CamundaDescriptor.js'; +import { DocumentNamespaces } from '../../../domain/document/vendor/DocumentNamespaces.js'; +import { ZEEBE } from '../../../domain/document/vendor/ZeebeDescriptor.js'; +import { ZEEBE_DESCRIPTOR } from '../../../domain/document/vendor/zeebe-descriptor.js'; +import { XmlTextError } from '../../../domain/document/xml/XmlTextError.js'; +import { DecisionLinkReader } from '../../../domain/editing/properties/DecisionLinkReader.js'; + +/** + * The core helpers the lazily loaded engine run gets handed instead of importing them: a module + * shared by the core and a rule chunk lands in the host's entry chunk, and the rule chunk would + * import the entry chunk back (a host that awaits a call at top level would wait for itself). + */ +export const ENGINE_KIT = { + BpmnModdle, + kindLabel, + makeFinding, + editDistance, + BPMN_NS, + is, + isBpmnElement, + isDiElement, + list, + peek, + walk, + decodeXml, + elementChildren, + readXmlText, + C7_URIS, + CAMUNDA, + CAMUNDA_DESCRIPTOR, + CAMUNDA_URI, + OPERATON_URI, + DocumentNamespaces, + ZEEBE, + ZEEBE_DESCRIPTOR, + XmlTextError, + DecisionLinkReader, +}; + +export type EngineKit = typeof ENGINE_KIT; diff --git a/src/domain/validation/profiles/BpmnSchema.ts b/src/adapters/out/bpmnlint/engine/BpmnSchema.ts similarity index 100% rename from src/domain/validation/profiles/BpmnSchema.ts rename to src/adapters/out/bpmnlint/engine/BpmnSchema.ts diff --git a/src/adapters/out/bpmnlint/engine/CamundaBooleans.ts b/src/adapters/out/bpmnlint/engine/CamundaBooleans.ts new file mode 100644 index 00000000..3f63d2a8 --- /dev/null +++ b/src/adapters/out/bpmnlint/engine/CamundaBooleans.ts @@ -0,0 +1,76 @@ +import type { Doc } from '../../../../domain/document/Doc.js'; +import type { ProfileFinding } from '../../../../domain/document/Finding.js'; + +import type { El } from '../../../../domain/document/utils/moddle.js'; + + +import type { DocumentNamespaces } from '../../../../domain/document/vendor/DocumentNamespaces.js'; +import type { EngineKit } from '../engine-kit.js'; + +/** How `bpmn set` addresses a nested element of a flow node or flow. */ +const SLOTS: Array<[string, string]> = [['eventDefinitions', 'definition.'], ['loopCharacteristics', 'loop.'], ['conditionExpression', 'condition.']]; + +/** + * camunda booleans other than the exact `true` / `false`: the engines read only `true`, so + * `yes` or `TRUE` leave a setting off. A typed parse turns such a value into `false` before a + * bpmnlint rule sees it; bpmn-cli's model keeps the text. + */ +export class CamundaBooleans { + private readonly ns: DocumentNamespaces; + + constructor(private readonly kit: EngineKit, private readonly doc: Doc) { + this.ns = new this.kit.DocumentNamespaces(doc); + } + + findings(): ProfileFinding[] { + const out: ProfileFinding[] = []; + for (const el of this.kit.walk(this.doc.definitions, { bpmnOnly: true })) { + this.checkAttributes(el, out); + for (const value of this.kit.list(this.kit.peek(el, 'extensionElements'), 'values')) this.checkVendorElement(el, value, '', out); + } + return out; + } + + private checkAttributes(el: El, out: ProfileFinding[]): void { + for (const [key, raw] of Object.entries(el.$attrs ?? {})) { + if (!this.kit.CAMUNDA.reads(this.ns.uriOfName(key))) continue; + const def = this.kit.CAMUNDA.attr(key.slice(key.indexOf(':') + 1)); + if (def?.type !== 'Boolean' || !def.owners.some((ownerType) => this.kit.is(el, ownerType))) continue; + const value = String(raw ?? ''); + if (value === 'true' || value === 'false') continue; + const address = this.addressOf(el); + out.push(this.kit.makeFinding('runtime', 'W_C7_BAD_VALUE', `${key}="${value}" on ${this.describe(el)} is not true or false; the engines read only the exact value true (yes, TRUE or True leave it off)`, address?.id, { subject: `attr:${key}`, hint: address ? `\`bpmn set ${address.id} ${address.slot}${key}=true\` (or =false).` : 'Use true or false in the XML.' })); + } + } + + private checkVendorElement(host: El, vendorElement: El, path: string, out: ProfileFinding[]): void { + if (!this.kit.CAMUNDA.reads(this.ns.uriOfElement(vendorElement))) return; + const local = vendorElement.$type.slice(vendorElement.$type.indexOf(':') + 1); + const vendorType = this.kit.CAMUNDA.type(local); + if (!vendorType) return; + for (const property of vendorType.properties) { + if (property.type !== 'Boolean' || !property.isAttr) continue; + const raw = this.kit.peek(vendorElement, property.name); + if (raw === undefined || raw === true || raw === false || raw === 'true' || raw === 'false') continue; + const address = this.addressOf(host); + out.push(this.kit.makeFinding('runtime', 'W_C7_BAD_VALUE', `${property.name}="${String(raw)}" on ${vendorElement.$type}${path ? ` (in ${path})` : ''} of ${this.describe(host)} is not true or false; the engines read only the exact value true`, address?.id, { subject: `ext:${path}/${local}@${property.name}`, hint: address ? `See it with \`bpmn ext list ${address.id} --json\`, then rebuild it with \`bpmn ext add ${address.id} ... --replace\` and ${property.name}=true (or false).` : 'Use true or false in the XML.' })); + } + for (const child of this.kit.list(vendorElement, '$children')) this.checkVendorElement(host, child, path ? `${path} > ${vendorElement.$type}` : vendorElement.$type, out); + } + + /** The element `bpmn set` names, and the slot of a nested element in it. */ + private addressOf(el: El): { id: string; slot: string } | undefined { + const id = this.kit.peek(el, 'id'); + const parent = el.$parent as El | undefined; + const parentId = parent ? this.kit.peek(parent, 'id') : undefined; + const slot = parent && parentId ? SLOTS.find(([property]) => { const value = this.kit.peek(parent, property); return Array.isArray(value) ? value.includes(el) : value === el; }) : undefined; + if (slot && parentId) return { id: parentId, slot: slot[1] }; + return id ? { id, slot: '' } : undefined; + } + + private describe(el: El): string { + const local = el.$type.slice(el.$type.indexOf(':') + 1); + const id = this.kit.peek(el, 'id'); + return id ? `${local.charAt(0).toLowerCase()}${local.slice(1)} ${id}` : el.$type; + } +} diff --git a/src/adapters/out/bpmnlint/engine/CompatHints.ts b/src/adapters/out/bpmnlint/engine/CompatHints.ts new file mode 100644 index 00000000..26dd0bc5 --- /dev/null +++ b/src/adapters/out/bpmnlint/engine/CompatHints.ts @@ -0,0 +1,77 @@ +import type { BpmnlintEngineReport } from '../bpmnlint-contract.js'; + +interface Node { + $type?: string; + id?: string; + $parent?: Node; + cancelActivity?: boolean; + triggeredByEvent?: boolean; + values?: Node[]; + [property: string]: unknown; +} + +/** The kind `bpmn retype` makes of an element type the engine does not support. */ +const REPLACEMENTS: Record = { + 'bpmn:Transaction': '`bpmn retype {id} subProcess` (an embedded sub-process).', + 'bpmn:ComplexGateway': '`bpmn retype {id} inclusiveGateway` (or exclusiveGateway: one path).', +}; + +/** + * The hints for camunda-compat reports where the one derived from `path` and `data.type` would be + * vague or wrong: a missing start event, a timer cycle where the engine refuses one, an element the + * engine does not support, a duplicated header, a standard loop. + */ +export class CompatHints { + of(rule: string, report: BpmnlintEngineReport, host: Node): string | undefined { + const id = String(host.id); + const type = report.data?.type; + const path = report.path ?? []; + if (rule === 'start-event-required') return this.startEvent(id, host); + if (rule === 'camunda-compat/history-time-to-live') return `\`bpmn set ${id} camunda:historyTimeToLive=180\` (days, like Camunda Modeler; P180D works too).`; + if (type === 'camunda.propertyNotAllowed' && path[path.length - 1] === 'timeCycle') return this.timerCycle(id, host); + if (type === 'camunda.elementTypeNotAllowed') return this.unsupported(id, host, report); + if (type === 'camunda.attachedToRefElementTypeNotAllowed') return `Attach it elsewhere (\`bpmn move ${id} --on \`) or remove it: \`bpmn remove ${id}\`.`; + if (type === 'camunda.propertyTypeNotAllowed' && path[0] === 'loopCharacteristics') return `Remove it (\`bpmn set ${id} loop=none\`) and model the loop with a gateway, or make it a multi-instance: \`bpmn set ${id} loop=sequential\`, then \`bpmn ext add ${id} loop.zeebe:loopCharacteristics inputCollection== inputElement=\`.`; + if (type === 'camunda.elementMultipleNotAllowed' && !path.length) return `Remove ${id} (\`bpmn remove ${id}\`) or give it a trigger: \`bpmn set ${id} trigger=message message=\`.`; + if (type === 'camunda.propertyValueDuplicated') return this.duplicatedChild(id, report); + if (type === 'camunda.extensionElementRequired') return this.unprefixedDecision(id, host, report); + return undefined; + } + + /** A business rule task that names its decision without the zeebe prefix: `bpmn set` writes the zeebe:calledDecision. */ + private unprefixedDecision(id: string, host: Node, report: BpmnlintEngineReport): string | undefined { + const required = report.data?.requiredExtensionElement; + const decision = (host.$attrs as Record | undefined)?.['calledDecision']; + if (!Array.isArray(required) || !required.includes('zeebe:CalledDecision') || typeof decision !== 'string') return undefined; + return `Give it its decision: \`bpmn set ${id} calledDecision=${decision}\`.`; + } + + private startEvent(id: string, host: Node): string { + return host.$type === 'bpmn:Process' ? `\`bpmn add startEvent "" --before \` (or --in ${id}).` : `\`bpmn add startEvent "" --in ${id}\`, then connect it to the first node.`; + } + + /** A catch event takes a duration or a date; an interrupting boundary or event sub-process start no cycle. */ + private timerCycle(id: string, host: Node): string { + if (host.$type === 'bpmn:IntermediateCatchEvent') return `\`bpmn set ${id} timer=PT1H\` (a duration, or a date-time).`; + return `Make it non-interrupting (\`bpmn set ${id} nonInterrupting=true\`) or use a duration (\`bpmn set ${id} timer=PT1H\`).`; + } + + private unsupported(id: string, host: Node, report: BpmnlintEngineReport): string | undefined { + if (report.data?.eventDefinition === 'bpmn:CancelEventDefinition' || (report.data?.eventDefinition as Node | undefined)?.$type === 'bpmn:CancelEventDefinition') return `\`bpmn set ${id} trigger=none\`.`; + return REPLACEMENTS[String(host.$type)]?.replace('{id}', id); + } + + /** Two children of one container with the same key: the second goes. */ + private duplicatedChild(id: string, report: BpmnlintEngineReport): string | undefined { + const container = report.data?.node as Node | undefined; + const second = (report.data?.properties as Node[] | undefined)?.[1]; + const index = second && container?.values ? container.values.indexOf(second) : -1; + if (!container?.$type || !second?.$type || index < 0) return undefined; + return `Keep one: \`bpmn ext remove ${id} '${this.xmlName(container.$type)}/${this.xmlName(second.$type)}[${index}]'\`.`; + } + + private xmlName(type: string): string { + const colon = type.indexOf(':'); + return `${type.slice(0, colon + 1)}${type.charAt(colon + 1).toLowerCase()}${type.slice(colon + 2)}`; + } +} diff --git a/src/adapters/out/bpmnlint/engine/CompatVerdicts.ts b/src/adapters/out/bpmnlint/engine/CompatVerdicts.ts new file mode 100644 index 00000000..cb519210 --- /dev/null +++ b/src/adapters/out/bpmnlint/engine/CompatVerdicts.ts @@ -0,0 +1,91 @@ +import type { BpmnlintEngineReport } from '../bpmnlint-contract.js'; + +/** + * camunda-compat's rules that report a property of white space only as missing, where Camunda 8.9 + * deploys it (engine-checked): a job type, message, signal, process, decision or listener name, a + * result variable, an error or escalation code thrown, a link name. + */ +const BLANK_PASSES = ['implementation', 'message-reference', 'signal-reference', 'called-element', 'execution-listener', 'error-reference', 'escalation-reference', 'link-event', 'variable-name'].map((rule) => `camunda-compat/${rule}`); +const COMPAT_TIMER = 'camunda-compat/timer'; +const FLOW_CONDITION = 'camunda-compat/sequence-flow-condition'; +/** camunda-compat says from 8.10 on; Camunda 8.9 deploys it (engine-checked). */ +const JOB_PRIORITY = { rule: 'camunda-compat/no-job-priority-definition', since: [8, 9] }; +const OWN_DUPLICATES = 'bpmn-cli/c8-duplicate-extension'; +/** Reported on every element of a group; the engine refuses the second and later ones. */ +const REPEATS = new Set(['camunda.elementMultipleNotAllowed', 'camunda.elementPropertyValueDuplicated']); + +/** + * Findings camunda-compat reports as refused at deploy that Camunda 8.9 deploys (engine-checked): + * the flow out of an exclusive or inclusive gateway is never taken, a standard loop runs once. + */ +const DEPLOYS: Record = { + 'camunda-compat/sequence-flow-condition': 'camunda.propertyRequired', + 'camunda-compat/loop-characteristics': 'camunda.propertyTypeNotAllowed', +}; + +type Results = Record; + +interface Node { + id?: string; + $parent?: Node; + $instanceOf?: (type: string) => boolean; + sourceRef?: Node; +} + +/** Where camunda-compat's verdict on a Camunda 8 file is not the engine's, its reports are dropped or made runtime findings. */ +export class CompatVerdicts { + constructor( + private readonly elementsById: Readonly>, + private readonly version: string | undefined, + ) {} + + apply(results: Results): void { + const duplicated = new Set((results[OWN_DUPLICATES] ?? []).map((report) => report.id)); + for (const [rule, reports] of Object.entries(results)) { + if (!rule.startsWith('camunda-compat/')) continue; + const kept = reports.filter((report) => !this.dropped(rule, report, duplicated)); + for (const report of kept) if (DEPLOYS[rule] === report.data?.type && report.category === 'error') report.category = 'warn'; + results[rule] = this.withoutFirstOfRepeats(kept); + } + } + + private dropped(rule: string, report: BpmnlintEngineReport, duplicated: ReadonlySet): boolean { + const type = report.data?.type; + if (duplicated.has(report.id) || duplicated.has((report.data?.node as Node | undefined)?.id)) return true; + if (BLANK_PASSES.includes(rule) && this.isBlank(report)) return true; + if (rule === COMPAT_TIMER && type === 'camunda.expressionValueNotAllowed') return true; + if (rule === FLOW_CONDITION && type === 'camunda.propertyNotAllowed') return this.leavesGateway(report); + return rule === JOB_PRIORITY.rule && this.runsAtLeast(JOB_PRIORITY.since); + } + + /** The first element of a group is the one the engine keeps: only the others are reported. */ + private withoutFirstOfRepeats(reports: BpmnlintEngineReport[]): BpmnlintEngineReport[] { + const seen = new Set(); + return reports.filter((report) => { + if (!REPEATS.has(report.data?.type ?? '')) return true; + const node = report.id ? (this.elementsById[report.id] as Node | undefined) : undefined; + const group = `${report.data?.type}|${node?.$parent?.id ?? ''}|${String(report.data?.duplicatedPropertyValue ?? '')}`; + if (seen.has(group)) return true; + seen.add(group); + return false; + }); + } + + /** A condition on a flow out of a gateway is the structural W_CONDITION_IGNORED's. */ + private leavesGateway(report: BpmnlintEngineReport): boolean { + const flow = report.id ? (this.elementsById[report.id] as Node | undefined) : undefined; + return flow?.sourceRef?.$instanceOf?.('bpmn:Gateway') === true; + } + + private runsAtLeast([major, minor]: number[]): boolean { + const [fileMajor, fileMinor] = (this.version ?? '').split('.').map(Number); + if (fileMajor === undefined || Number.isNaN(fileMajor) || fileMinor === undefined || Number.isNaN(fileMinor)) return true; + return fileMajor !== major ? fileMajor > major! : fileMinor >= minor!; + } + + private isBlank(report: BpmnlintEngineReport): boolean { + let value: unknown = report.id ? this.elementsById[report.id] : undefined; + for (const step of report.path ?? []) value = (value as Record | undefined)?.[step]; + return typeof value === 'string' && value !== '' && value.trim() === ''; + } +} diff --git a/src/adapters/out/bpmnlint/engine/EngineCodes.ts b/src/adapters/out/bpmnlint/engine/EngineCodes.ts new file mode 100644 index 00000000..b405180f --- /dev/null +++ b/src/adapters/out/bpmnlint/engine/EngineCodes.ts @@ -0,0 +1,155 @@ +import type { BpmnlintEngineReport } from '../bpmnlint-contract.js'; + +/** A code, or for a rule whose findings split by kind: camunda-compat's `data.type` to a code, `default` for the rest. */ +type EngineCode = string | { default: string; [dataType: string]: string }; + +/** + * The bpmn-cli code of every engine rule, keyed as bpmnlint reports it (`camunda-compat/`, + * `bpmn-cli/`, a bpmnlint rule bare): the agent-facing W_C7_* / W_C8_* codes. + */ +export class EngineCodes { + static readonly TABLE: Readonly>>> = { + c7: { + 'camunda-compat/history-time-to-live': 'W_C7_DEPLOY_HISTORY_TTL', + 'bpmn-cli/c7-ad-hoc-sub-process': 'W_C7_DEPLOY_AD_HOC_SUBPROCESS', + 'bpmn-cli/c7-async-duplicate-extension': 'W_C7_DUPLICATE_EXTENSION', + 'bpmn-cli/c7-attribute-value': 'W_C7_DEPLOY_BAD_VALUE', + 'bpmn-cli/c7-bad-value': 'W_C7_BAD_VALUE', + 'bpmn-cli/c7-binding-version': 'W_C7_DEPLOY_BINDING_VERSION', + 'bpmn-cli/c7-boundary-host': 'W_C7_DEPLOY_BOUNDARY_HOST', + 'bpmn-cli/c7-called-element': 'W_C7_DEPLOY_CALLED_ELEMENT', + 'bpmn-cli/c7-complex-gateway': 'W_C7_DEPLOY_COMPLEX_GATEWAY', + 'bpmn-cli/c7-dangling-reference': 'W_C7_DANGLING_REF', + 'bpmn-cli/c7-definitions-extension': 'W_C7_DEPLOY_DEFINITIONS_EXTENSION', + 'bpmn-cli/c7-duplicate-extension': 'W_C7_DEPLOY_DUPLICATE_EXTENSION', + 'bpmn-cli/c7-empty-condition': 'W_C7_EMPTY_CONDITION', + 'bpmn-cli/c7-error': 'W_C7_DEPLOY_ERROR', + 'bpmn-cli/c7-escalation': 'W_C7_DEPLOY_ESCALATION', + 'bpmn-cli/c7-event-definition': 'W_C7_DEPLOY_EVENT_DEFINITION', + 'bpmn-cli/c7-event-gateway': 'W_C7_DEPLOY_EVENT_GATEWAY', + 'bpmn-cli/c7-exclusive-gateway': 'W_C7_DEPLOY_EXCLUSIVE_GATEWAY', + 'bpmn-cli/c7-extension': 'W_C7_DEPLOY_EXTENSION', + 'bpmn-cli/c7-external-topic': 'W_C7_DEPLOY_EXTERNAL_TOPIC', + 'bpmn-cli/c7-foreign-content': 'W_C7_FOREIGN_CONTENT', + 'bpmn-cli/c7-form': 'W_C7_DEPLOY_FORM', + 'bpmn-cli/c7-history-time-to-live': 'W_C7_DEPLOY_HISTORY_TTL', + 'bpmn-cli/c7-implementation': 'W_C7_DEPLOY_IMPLEMENTATION', + 'bpmn-cli/c7-implicit-default-flow': 'W_C7_EXCLUSIVE_GATEWAY_DEFAULT', + 'bpmn-cli/c7-input-output': 'W_C7_DEPLOY_INPUT_OUTPUT', + 'bpmn-cli/c7-link-event': 'W_C7_DEPLOY_LINK', + 'bpmn-cli/c7-listener': 'W_C7_DEPLOY_LISTENER', + 'bpmn-cli/c7-message': 'W_C7_DEPLOY_MESSAGE', + 'bpmn-cli/c7-message-implementation': 'W_C7_MESSAGE_NO_IMPLEMENTATION', + 'bpmn-cli/c7-misplaced-attribute': 'W_C7_MISPLACED_ATTRIBUTE', + 'bpmn-cli/c7-misplaced-extension': 'W_C7_MISPLACED_EXTENSION', + 'bpmn-cli/c7-multi-instance': 'W_C7_DEPLOY_MULTI_INSTANCE', + 'bpmn-cli/c7-multiple-event-definitions': 'W_C7_MULTIPLE_EVENT_DEFINITIONS', + 'bpmn-cli/c7-result-variable': 'W_C7_DEPLOY_RESULT_VARIABLE', + 'bpmn-cli/c7-script': 'W_C7_DEPLOY_SCRIPT', + 'bpmn-cli/c7-signal': 'W_C7_DEPLOY_SIGNAL', + 'bpmn-cli/c7-start-event': 'W_C7_DEPLOY_START_EVENT', + 'bpmn-cli/c7-transaction-start': 'W_C7_TRANSACTION_NO_START', + 'bpmn-cli/c7-unknown-attribute': 'W_C7_UNKNOWN_ATTRIBUTE', + }, + c8: { + 'start-event-required': 'W_C8_DEPLOY_START_EVENT', + 'camunda-compat/ad-hoc-sub-process': 'W_C8_DEPLOY_AD_HOC_SUBPROCESS', + 'camunda-compat/agent-fromai-contract': 'W_C8_DEPLOY_EXTENSION', + 'camunda-compat/agent-tool-documentation': 'W_C8_DEPLOY_EXTENSION', + 'camunda-compat/agent-tool-output-key': 'W_C8_DEPLOY_EXTENSION', + 'camunda-compat/before-all-execution-listener': 'W_C8_DEPLOY_LISTENER', + 'camunda-compat/called-element': 'W_C8_DEPLOY_CALLED_ELEMENT', + 'camunda-compat/cancel-execution-listener': 'W_C8_DEPLOY_LISTENER', + 'camunda-compat/collapsed-subprocess': 'W_C8_DEPLOY_UNSUPPORTED', + 'camunda-compat/connector-properties': 'W_C8_DEPLOY_EXTENSION', + 'camunda-compat/duplicate-execution-listener-headers': 'W_C8_DEPLOY_LISTENER', + 'camunda-compat/duplicate-execution-listeners': 'W_C8_DEPLOY_LISTENER', + 'camunda-compat/duplicate-task-headers': 'W_C8_DEPLOY_EXTENSION', + 'camunda-compat/element-type': 'W_C8_DEPLOY_UNSUPPORTED', + 'camunda-compat/error-reference': 'W_C8_DEPLOY_ERROR', + 'camunda-compat/escalation-boundary-event-attached-to-ref': 'W_C8_DEPLOY_ESCALATION', + 'camunda-compat/escalation-reference': 'W_C8_DEPLOY_ESCALATION', + 'camunda-compat/event-based-gateway-target': 'W_C8_DEPLOY_EVENT_GATEWAY', + 'camunda-compat/executable-process': 'W_C8_DEPLOY_EXECUTABLE', + 'camunda-compat/execution-listener': 'W_C8_DEPLOY_LISTENER', + 'camunda-compat/feel': 'W_C8_DEPLOY_EXPRESSION', + 'camunda-compat/feel-compatibility': 'W_C8_DEPLOY_EXPRESSION', + 'camunda-compat/implementation': 'W_C8_DEPLOY_IMPLEMENTATION', + 'camunda-compat/inclusive-gateway': 'W_C8_DEPLOY_UNSUPPORTED', + 'camunda-compat/io-mapping': 'W_C8_DEPLOY_EXTENSION', + 'camunda-compat/link-event': 'W_C8_DEPLOY_LINK', + 'camunda-compat/loop-characteristics': { default: 'W_C8_DEPLOY_MULTI_INSTANCE', 'camunda.propertyTypeNotAllowed': 'W_C8_STANDARD_LOOP' }, + 'camunda-compat/message-reference': 'W_C8_DEPLOY_MESSAGE', + 'camunda-compat/no-before-all-execution-listener': 'W_C8_DEPLOY_LISTENER', + 'camunda-compat/no-binding-type': 'W_C8_DEPLOY_EXTENSION', + 'camunda-compat/no-business-id': 'W_C8_DEPLOY_CALLED_ELEMENT', + 'camunda-compat/no-cancel-execution-listener': 'W_C8_DEPLOY_LISTENER', + 'camunda-compat/no-candidate-users': 'W_C8_DEPLOY_USER_TASK', + 'camunda-compat/no-execution-listener-headers': 'W_C8_DEPLOY_LISTENER', + 'camunda-compat/no-execution-listeners': 'W_C8_DEPLOY_LISTENER', + 'camunda-compat/no-expression': 'W_C8_DEPLOY_EXPRESSION', + 'camunda-compat/no-interrupting-event-subprocess': 'W_C8_DEPLOY_UNSUPPORTED', + 'camunda-compat/no-job-priority-definition': 'W_C8_DEPLOY_EXTENSION', + 'camunda-compat/no-loop': 'W_C8_DEPLOY_UNSUPPORTED', + 'camunda-compat/no-multiple-none-start-events': 'W_C8_DEPLOY_START_EVENT', + 'camunda-compat/no-priority-definition': 'W_C8_DEPLOY_USER_TASK', + 'camunda-compat/no-propagate-all-parent-variables': 'W_C8_DEPLOY_CALLED_ELEMENT', + 'camunda-compat/no-signal-event-sub-process': 'W_C8_DEPLOY_UNSUPPORTED', + 'camunda-compat/no-task-listeners': 'W_C8_DEPLOY_LISTENER', + 'camunda-compat/no-task-schedule': 'W_C8_DEPLOY_USER_TASK', + 'camunda-compat/no-version-tag': 'W_C8_DEPLOY_EXTENSION', + 'camunda-compat/no-zeebe-properties': 'W_C8_DEPLOY_EXTENSION', + 'camunda-compat/no-zeebe-user-task': 'W_C8_DEPLOY_USER_TASK', + 'camunda-compat/priority-definition': 'W_C8_DEPLOY_USER_TASK', + 'camunda-compat/secrets': 'W_C8_DEPLOY_EXTENSION', + 'camunda-compat/sequence-flow-condition': { default: 'W_C8_EXCLUSIVE_GATEWAY', 'camunda.propertyNotAllowed': 'W_C8_CONDITION_IGNORED' }, + 'camunda-compat/signal-reference': 'W_C8_DEPLOY_SIGNAL', + 'camunda-compat/start-event-form': 'W_C8_DEPLOY_EXTENSION', + 'camunda-compat/start-event-form-embedded': 'W_C8_DEPLOY_EXTENSION', + 'camunda-compat/subscription': 'W_C8_DEPLOY_MESSAGE', + 'camunda-compat/task-listener': 'W_C8_DEPLOY_LISTENER', + 'camunda-compat/task-schedule': 'W_C8_DEPLOY_USER_TASK', + 'camunda-compat/timer': 'W_C8_DEPLOY_TIMER', + 'camunda-compat/unresolvable-secret-reference': 'W_C8_DEPLOY_EXTENSION', + 'camunda-compat/user-task-definition': 'W_C8_DEPLOY_USER_TASK', + 'camunda-compat/user-task-form': 'W_C8_DEPLOY_USER_TASK', + 'camunda-compat/variable-name': 'W_C8_DEPLOY_EXTENSION', + 'camunda-compat/version-tag': 'W_C8_DEPLOY_EXTENSION', + 'camunda-compat/wait-for-completion': 'W_C8_DEPLOY_EVENT_DEFINITION', + 'camunda-compat/zeebe-user-task': 'W_C8_JOB_WORKER_USER_TASK', + 'bpmn-cli/c8-ad-hoc-sub-process': 'W_C8_DEPLOY_AD_HOC_SUBPROCESS', + 'bpmn-cli/c8-bad-value': 'W_C8_BAD_VALUE', + 'bpmn-cli/c8-boundary-host': 'W_C8_DEPLOY_BOUNDARY_HOST', + 'bpmn-cli/c8-duplicate-extension': 'W_C8_DEPLOY_DUPLICATE_EXTENSION', + 'bpmn-cli/c8-error': 'W_C8_DEPLOY_ERROR', + 'bpmn-cli/c8-event-definition': 'W_C8_DEPLOY_EVENT_DEFINITION', + 'bpmn-cli/c8-event-gateway': 'W_C8_DEPLOY_EVENT_GATEWAY', + 'bpmn-cli/c8-extension': 'W_C8_DEPLOY_EXTENSION', + 'bpmn-cli/c8-feel-completion-condition': 'W_C8_EXPRESSION', + 'bpmn-cli/c8-feel-expression': 'W_C8_DEPLOY_EXPRESSION', + 'bpmn-cli/c8-foreign-content': 'W_C8_FOREIGN_CONTENT', + 'bpmn-cli/c8-implementation': 'W_C8_DEPLOY_IMPLEMENTATION', + 'bpmn-cli/c8-link-event': 'W_C8_DEPLOY_LINK', + 'bpmn-cli/c8-listener': 'W_C8_DEPLOY_LISTENER', + 'bpmn-cli/c8-message': 'W_C8_DEPLOY_MESSAGE', + 'bpmn-cli/c8-misplaced-attribute': 'W_C8_MISPLACED_ATTRIBUTE', + 'bpmn-cli/c8-misplaced-extension': 'W_C8_MISPLACED_EXTENSION', + 'bpmn-cli/c8-schema': 'W_C8_DEPLOY_SCHEMA', + 'bpmn-cli/c8-signal': 'W_C8_DEPLOY_SIGNAL', + 'bpmn-cli/c8-start-event': 'W_C8_DEPLOY_START_EVENT', + 'bpmn-cli/c8-timer-value': 'W_C8_DEPLOY_TIMER', + 'bpmn-cli/c8-unknown-attribute': 'W_C8_UNKNOWN_ATTRIBUTE', + }, + }; + + /** What a rule reports that bpmn-cli has no code for yet (a rule a newer camunda-compat adds). */ + static readonly UNMAPPED: Readonly> = { c7: 'W_C7_ENGINE_RULE', c8: 'W_C8_ENGINE_RULE' }; + + static codeOf(platform: 'c7' | 'c8', rule: string, report: BpmnlintEngineReport): string { + const code = EngineCodes.TABLE[platform][rule]; + if (code === undefined) return EngineCodes.UNMAPPED[platform]; + if (typeof code === 'string') return code; + const type = report.data?.type; + return (type !== undefined ? code[type] : undefined) ?? code.default; + } +} diff --git a/src/adapters/out/bpmnlint/engine/EngineFindings.ts b/src/adapters/out/bpmnlint/engine/EngineFindings.ts new file mode 100644 index 00000000..d89ed8f4 --- /dev/null +++ b/src/adapters/out/bpmnlint/engine/EngineFindings.ts @@ -0,0 +1,123 @@ +import type { ProfileFinding, Severity } from '../../../../domain/document/Finding.js'; + +import type { BpmnlintEngineReport } from '../bpmnlint-contract.js'; +import { EngineCodes } from './EngineCodes.js'; +import { EngineHint } from './EngineHint.js'; +import type { EngineKit } from '../engine-kit.js'; + +/** The engine layers set what a finding means by its bpmnlint category. */ +const SEVERITY_OF_CATEGORY: Record = { error: 'deploy', warn: 'runtime', info: 'practice' }; + +const OWN_RULES = 'bpmn-cli/'; + +interface ModdleNode { + $type?: string; + id?: string; + $instanceOf?: unknown; + $parent?: ModdleNode; + $descriptor?: { propertiesByName?: Record }; + extensionElements?: { values?: ModdleNode[] }; +} + +/** + * The engine rules' bpmnlint reports as bpmn-cli findings: the code from the rule, the severity + * from the category, the hint from `path` and `data.type`, and the subject (what a mutation tells + * apart on one element) from the path, an extension element as `ext:` and a vendor + * attribute as `attr:`, as the operation warnings name what they cover. + */ +export class EngineFindings { + private readonly hint: EngineHint; + + /** `spelling`: the attribute as the file writes it (`operaton:assignee` for the parse's `camunda:assignee`). */ + constructor( + private readonly kit: EngineKit, + private readonly platform: 'c7' | 'c8', + private readonly elementsById: Readonly>, + private readonly spelling: (id: string, key: string) => string = (_id, key) => key, + private readonly zeebePrefix = 'zeebe', + ) { + this.hint = new EngineHint(elementsById); + } + + /** + * One finding per subject of an element: where an own rule and camunda-compat both report it + * (a misplaced zeebe:taskDefinition), the own rule's says what bpmn-cli's code says; where two + * camunda-compat rules report it with one code (io-mapping and variable-name), the first says it. + */ + of(results: Record): ProfileFinding[] { + const out: ProfileFinding[] = []; + const ownSubjects = new Set(); + const compat: Array<{ finding: ProfileFinding; identity: string }> = []; + for (const [rule, reports] of Object.entries(results)) { + for (const report of reports) { + const severity = SEVERITY_OF_CATEGORY[report.category] ?? 'runtime'; + const related = this.relatedIds(report); + const subject = this.subjectOf(report); + const finding = this.kit.makeFinding(severity, EngineCodes.codeOf(this.platform, rule, report), String(report.message), report.id ?? undefined, { hint: this.spelledHint(this.hint.of(report, rule), rule), subject, ...(related.length ? { related } : {}) }); + const identity = `${report.id ?? ''}|${subject}`; + if (rule.startsWith(OWN_RULES)) { + if (subject) ownSubjects.add(identity); + out.push(finding); + } else { + compat.push({ finding, identity }); + } + } + } + const reported = new Set(); + const firstOfSubject = ({ finding, identity }: { finding: ProfileFinding; identity: string }): boolean => { + if (!finding.subject) return true; + const key = `${identity}|${finding.code}`; + if (ownSubjects.has(identity) || reported.has(key)) return false; + reported.add(key); + return true; + }; + return [...out, ...compat.filter(firstOfSubject).map(({ finding }) => finding)]; + } + + private subjectOf(report: BpmnlintEngineReport): string { + const path = report.path ?? []; + const at = path.indexOf('extensionElements'); + const host = report.id ? (this.elementsById[report.id] as ModdleNode | undefined) : undefined; + if (at === 0 && path[1] === 'values' && typeof path[2] === 'number') { + const type = host?.extensionElements?.values?.[path[2]]?.$type; + const local = type?.slice(type.indexOf(':') + 1); + if (local) return [`ext:${local.charAt(0).toLowerCase()}${local.slice(1)}`, ...path.slice(3)].join('.'); + } + if (path.length === 1 && typeof path[0] === 'string') return `attr:${report.id ? this.spelling(report.id, this.attributeKey(host, path[0])) : this.attributeKey(host, path[0])}`; + return path.join('.'); + } + + /** A vendor attribute as the operation warnings name it, with its prefix (`camunda:class`). */ + private attributeKey(host: ModdleNode | undefined, name: string): string { + if (name.includes(':')) return name; + const prefix = host?.$descriptor?.propertiesByName?.[name]?.ns?.prefix; + return prefix && prefix !== 'bpmn' ? `${prefix}:${name}` : name; + } + + /** An event definition or loop of the reported element is part of it, not another element; a flow node in it is one. */ + private isInside(node: ModdleNode, id: string | undefined): boolean { + for (let parent = node.$parent; parent; parent = parent.$parent) if (parent.id === id) return true; + return false; + } + + /** A camunda-compat hint names zeebe content as the file spells it; an own rule's hint does already. */ + private spelledHint(hint: string, rule: string): string { + return this.zeebePrefix === 'zeebe' || rule.startsWith(OWN_RULES) ? hint : hint.replace(/\bzeebe:/g, `${this.zeebePrefix}:`); + } + + private isFlowElement(node: ModdleNode): boolean { + return (node.$instanceOf as ((type: string) => boolean) | undefined)?.('bpmn:FlowElement') === true; + } + + /** The other elements a report names in its data (a referenced host, a gateway's flow, a duplicate). */ + private relatedIds(report: BpmnlintEngineReport): string[] { + const ids = new Set(); + for (const value of Object.values(report.data ?? {})) { + for (const item of Array.isArray(value) ? value : [value]) { + const id = item && typeof item === 'object' && typeof (item as ModdleNode).$instanceOf === 'function' ? (item as ModdleNode).id : undefined; + if (id && id !== report.id && !(this.isInside(item as ModdleNode, report.id) && !this.isFlowElement(item as ModdleNode))) ids.add(id); + } + } + return [...ids]; + } +} diff --git a/src/adapters/out/bpmnlint/engine/EngineHint.ts b/src/adapters/out/bpmnlint/engine/EngineHint.ts new file mode 100644 index 00000000..3da995ce --- /dev/null +++ b/src/adapters/out/bpmnlint/engine/EngineHint.ts @@ -0,0 +1,291 @@ +import type { BpmnlintEngineReport } from '../bpmnlint-contract.js'; +import { CompatHints } from './CompatHints.js'; + +/** camunda-compat's error types (`data.type`), the ones a hint tells apart. */ +const SET = new Set(['camunda.propertyRequired', 'camunda.propertyValueRequired', 'camunda.propertyDependentRequired', 'camunda.propertyValueNotAllowed', 'camunda.propertyTypeNotAllowed', 'camunda.expressionRequired', 'camunda.expressionValueNotAllowed', 'camunda.expressionNotAllowed', 'camunda.feelExpressionInvalid', 'camunda.feelExpressionUnsupportedFunction', 'camunda.propertyValueDuplicated', 'camunda.elementPropertyValueDuplicated', 'camunda.propertiesValuesDuplicated']); +const UNSET = new Set(['camunda.propertyNotAllowed', 'camunda.propertyDeprecated']); +const FEEL = new Set(['camunda.expressionRequired', 'camunda.feelExpressionInvalid', 'camunda.expressionValueNotAllowed']); +const EXT_ADD = new Set(['camunda.extensionElementRequired', 'camunda.childElementOfTypeRequired']); +const EXT_REMOVE = new Set(['camunda.extensionElementNotAllowed', 'camunda.elementMultipleNotAllowed', 'camunda.childElementTypeNotAllowed']); +const REMOVE = new Set(['camunda.elementTypeNotAllowed', 'camunda.attachedToRefElementTypeNotAllowed', 'camunda.loopNotAllowed', 'camunda.elementCollapsedNotAllowed', 'camunda.eventBasedGatewayTargetNotAllowed']); + +/** How `bpmn set` addresses an element nested in a flow node or flow. */ +const SLOTS: Record = { eventDefinitions: 'definition', loopCharacteristics: 'loop', conditionExpression: 'condition' }; + +/** What an alternative extension element does, for a hint that offers it next to a job. */ +const ALTERNATIVES: Record = { 'zeebe:script': 'a FEEL script', 'zeebe:calledDecision': 'call a DMN decision' }; + +/** The attributes an extension element needs to deploy, as `bpmn ext add` takes them. */ +const REQUIRED_ATTRIBUTES: Record = { + 'zeebe:taskDefinition': 'type=', + 'zeebe:calledElement': 'processId=', + 'zeebe:calledDecision': 'decisionId= resultVariable=', + 'zeebe:subscription': 'correlationKey==', + 'zeebe:loopCharacteristics': 'inputCollection== inputElement=', + 'zeebe:script': 'expression== resultVariable=', + 'zeebe:formDefinition': 'formId=', + 'camunda:field': 'name= stringValue=', +}; + +/** + * The `bpmn set` key that writes a property of a nested element, with its placeholder: the + * timer, message or condition of an event, the condition of a flow. + */ +const SET_ALIASES: Record = { + 'definition.timeDate': 'timer=', + 'definition.timeCycle': 'timer=', + 'definition.timeDuration': 'timer=PT1H', + 'definition.messageRef': 'message=', + 'definition.signalRef': 'signal=', + 'definition.errorRef': 'error= errorCode=', + 'definition.escalationRef': 'escalation= escalationCode=', + 'definition.condition': 'when=', + 'definition.name': 'link=', + messageRef: 'message=', + conditionExpression: 'condition=', + completionCondition: 'completion=', + 'camunda:type': 'camunda:type=external camunda:topic=', + 'definition.camunda:type': 'definition.camunda:type=external definition.camunda:topic=', +}; + +/** The placeholder a hint writes for a property, in the vocabulary of bpmn-cli's hints. */ +const PLACEHOLDERS: Record = { + resultVariable: '', + expression: '=', + type: '', + target: '', + source: '=', + processId: '', + decisionId: '', + formId: '', + versionTag: '', + inputElement: '', + outputElement: '=', + inputCollection: '=', + outputCollection: '', + correlationKey: '=', + errorCode: '', + escalationCode: '', + messageName: '', + historyTimeToLive: '180', +}; + +/** `bpmn set` keys of the event triggers, by event definition type. */ +const TRIGGERS: Record = { + 'bpmn:MessageEventDefinition': 'message', + 'bpmn:TimerEventDefinition': 'timer', + 'bpmn:SignalEventDefinition': 'signal', + 'bpmn:ErrorEventDefinition': 'error', + 'bpmn:EscalationEventDefinition': 'escalation', + 'bpmn:ConditionalEventDefinition': 'conditional', + 'bpmn:LinkEventDefinition': 'link', + 'bpmn:CompensateEventDefinition': 'compensation', + 'bpmn:TerminateEventDefinition': 'terminate', + 'bpmn:CancelEventDefinition': 'cancel', +}; + +interface ModdleNode { + $type?: string; + id?: string; + $descriptor?: { properties?: Array<{ name: string; isAttr?: boolean; ns?: { prefix?: string } }>; propertiesByName?: Record }; + [property: string]: unknown; +} + +/** Where a path leads: the element `bpmn` addresses, the slot of the nested element, the rest. */ +interface Address { + target: string; + slot: string; + node: ModdleNode; + rest: Array; +} + +/** + * The `bpmn` command that fixes an engine finding, derived from where the report points (`path`) + * and what kind of problem it is (`data.type`, camunda-compat's error types): one rule for all + * engine rules instead of a hint per rule. Without a command that fits, it says what to look at. + */ +export class EngineHint { + private readonly compat = new CompatHints(); + + constructor(private readonly elementsById: Readonly>) {} + + of(report: BpmnlintEngineReport, rule = ''): string { + const id = report.id; + const type = report.data?.type; + const host = id ? (this.elementsById[id] as ModdleNode | undefined) : undefined; + const own = report.data?.hint; + if (typeof own === 'string') return own; + const special = id && host ? this.compat.of(rule, report, host) : undefined; + if (special) return special; + if (!id || !host || !type) return this.fallback(id); + const path = report.path ?? []; + const requiredType = report.data?.requiredType; + if (type === 'camunda.childElementOfTypeRequired' && typeof requiredType === 'string' && requiredType.startsWith('bpmn:')) return this.addChild(id, host, requiredType); + if (type === 'camunda.elementMultipleNotAllowed' && path[path.length - 1] === 'eventDefinitions') return this.keepOneTrigger(id, report); + if (!path.length && EXT_REMOVE.has(type)) return `Remove it (\`bpmn remove ${id}\`), or give it another trigger (\`bpmn set ${id} trigger=\`).`; + const address = this.address(host, path); + if (!address) return this.fallback(id); + const ext = this.extensionStep(address); + if (EXT_ADD.has(type)) return this.extAdd(address, ext, report); + const child = ext && !EXT_REMOVE.has(type) && !UNSET.has(type) ? this.childRebuild(address, ext.element) : undefined; + if (child) return child; + if (ext) return EXT_REMOVE.has(type) || UNSET.has(type) ? `Remove it: \`bpmn ext remove ${address.target} ${this.quoted(`${address.slot}${ext.index}`)}\` (\`bpmn ext list ${address.target}\` shows the extension elements).` : this.extRebuild(address.target, address.slot, ext.element, ext.property, FEEL.has(type)); + if (REMOVE.has(type)) return `Remove it (\`bpmn remove ${id}\`), or change it to a kind the engine supports (\`bpmn retype ${id} \`, \`bpmn kinds\` lists them).`; + const key = this.attributeKey(address, report); + if (!key) return this.fallback(id); + const alias = SET_ALIASES[key.replace(/^definition\[\d+\]\./, 'definition.')]; + if (alias && !UNSET.has(type)) return `\`bpmn set ${address.target} ${alias.split(' ').map((pair) => this.quoted(FEEL.has(type) ? pair.replace(/=$/, '==') : pair)).join(' ')}\`.`; + if (UNSET.has(type)) return `Remove it: \`bpmn set ${address.target} ${this.quoted(`${key}=`)}\`.`; + const suggested = report.data?.suggestedValue ?? report.data?.requiredValue; + if (SET.has(type)) return `\`bpmn set ${address.target} ${this.quoted(`${key}=${typeof suggested === 'string' || typeof suggested === 'boolean' ? String(suggested) : FEEL.has(type) ? '=' : this.placeholder(key, address.node)}`)}\`.`; + return this.fallback(id); + } + + /** + * Follows the path from the reported element: into a nested element of a flow node (a slot of + * `bpmn set`) or a referenced root element (a message, an error), which `bpmn` then addresses. + */ + private address(host: ModdleNode, path: ReadonlyArray): Address | undefined { + let node = host; + let target = String(host.id); + let slot = ''; + let index = 0; + while (index < path.length) { + const step = path[index]!; + if (typeof step === 'number' || step === 'extensionElements') break; + const value = node[step]; + const isLast = index === path.length - 1; + if (isLast && !SLOTS[step]) break; + const property = node.$descriptor?.propertiesByName?.[step]; + if (property?.isReference && value && typeof value === 'object' && (value as ModdleNode).id) { + node = value as ModdleNode; + target = String(node.id); + slot = ''; + index++; + continue; + } + if (!SLOTS[step] || node !== host) break; + const many = Array.isArray(value) && value.length > 1; + const position = typeof path[index + 1] === 'number' ? (path[index + 1] as number) : 0; + const nested = Array.isArray(value) ? value[position] : value; + if (!nested || typeof nested !== 'object') break; + slot = `${SLOTS[step]}${many ? `[${position}]` : ''}.`; + node = nested as ModdleNode; + index += typeof path[index + 1] === 'number' ? 2 : 1; + } + return { target, slot, node, rest: path.slice(index) }; + } + + /** + * The `bpmn set` key of the attribute the rest of the path names (`camunda:topic`, + * `definition.name`); a path that ends at a nested element names the property it lacks in `data`. + */ + private attributeKey(address: Address, report: BpmnlintEngineReport): string | undefined { + const required = report.data?.requiredProperty; + if (!address.rest.length && !required && address.slot === 'condition.') return 'conditionExpression'; + const [step, ...more] = address.rest.length ? address.rest : [Array.isArray(required) ? required[required.length - 1] : required]; + if (typeof step !== 'string' || more.some((part) => typeof part === 'string')) return undefined; + const prefix = address.node.$descriptor?.propertiesByName?.[step]?.ns?.prefix; + const name = step.includes(':') || !prefix || prefix === 'bpmn' ? step : `${prefix}:${step}`; + return `${address.slot}${name}`; + } + + private extensionStep(address: Address): { index: number; element: ModdleNode; property?: string } | undefined { + const [first, values, index, property] = address.rest; + if (first !== 'extensionElements' || values !== 'values' || typeof index !== 'number') return undefined; + const element = ((address.node.extensionElements as ModdleNode | undefined)?.values as ModdleNode[] | undefined)?.[index]; + if (!element) return undefined; + return { index, element, ...(typeof property === 'string' ? { property } : {}) }; + } + + private extAdd(address: Address, ext: { element: ModdleNode } | undefined, report: BpmnlintEngineReport): string { + if (ext) return this.extRebuild(address.target, address.slot, ext.element, undefined, false); + const required = report.data?.requiredExtensionElement ?? report.data?.requiredChildElement; + const named = (Array.isArray(required) ? required : [required ?? '']).map((type) => this.xmlName(String(type))); + const types = named.includes('zeebe:taskDefinition') ? ['zeebe:taskDefinition', ...named.filter((type) => type !== 'zeebe:taskDefinition')] : named; + const present = types.filter((type) => this.extensionTypes(address.node).includes(type)); + if (present.length > 1) return `Keep one: ${present.map((type, index) => `${index ? '(or ' : ''}\`bpmn ext remove ${address.target} ${type}\`${index ? ')' : ''}`).join(' ')}.`; + const owner = report.data?.node as ModdleNode | undefined; + const target = owner?.id && owner.$type === 'bpmn:Message' ? owner.id : address.target; + const slot = target === address.target ? address.slot : ''; + const [first, ...others] = types; + const command = (type: string): string => { + const attributes = REQUIRED_ATTRIBUTES[type]; + return `\`bpmn ext add ${target} ${this.quoted(`${slot}${type}`)}${attributes ? ` ${attributes}` : ''}\``; + }; + const lead = first === 'zeebe:taskDefinition' ? 'Give it a job type: ' : ''; + const alternatives = others.map((type) => ` (or ${ALTERNATIVES[type] ?? 'one'}: ${command(type)})`).join(''); + const kinds = REQUIRED_ATTRIBUTES[first!] ? '' : ' (`bpmn kinds` lists the extension elements and their attributes)'; + return `${lead}${command(first!)}${alternatives}${kinds}.`; + } + + /** + * `bpmn ext add --replace` that writes the extension element anew with all its attributes and + * the one to fix; an element with child elements is rebuilt from its XML. + */ + private extRebuild(target: string, slot: string, element: ModdleNode, property: string | undefined, feel: boolean): string { + const type = this.xmlName(String(element.$type)); + const attributes = (element.$descriptor?.properties ?? []).filter((candidate) => candidate.isAttr && candidate.name !== property && Object.hasOwn(element, candidate.name) && element[candidate.name] !== undefined && typeof element[candidate.name] !== 'object').map((candidate) => this.quoted(`${candidate.name}=${String(element[candidate.name])}`)); + const required = REQUIRED_ATTRIBUTES[type]?.split(' ').filter((pair) => !attributes.some((attribute) => attribute.startsWith(`${pair.split('=')[0]}=`))) ?? []; + const fixed = property ? [this.quoted(`${property}=${feel ? '=' : (PLACEHOLDERS[property] ?? `<${property}>`)}`)] : required; + const hasChildren = (element.$descriptor?.properties ?? []).some((candidate) => !candidate.isAttr && element[candidate.name] !== undefined && typeof element[candidate.name] === 'object'); + if (hasChildren) return `See it with \`bpmn ext list ${target} --json\`, then rebuild it: \`bpmn ext add ${target} ${this.quoted(`${slot}${type}`)} --replace --xml '<${type} ...>...'\`.`; + return `\`bpmn ext add ${target} ${[this.quoted(`${slot}${type}`), ...attributes, ...fixed, '--replace'].join(' ')}\`.`; + } + + /** + * A child of a container extension (a listener, an input or output mapping) that lacks or has a + * wrong attribute: it is removed and added again, its siblings stay. + */ + private childRebuild(address: Address, container: ModdleNode): string | undefined { + const [, , , list, position, property] = address.rest; + if (typeof list !== 'string' || typeof position !== 'number' || typeof property !== 'string') return undefined; + const element = (container[list] as ModdleNode[] | undefined)?.[position]; + if (!element?.$type) return undefined; + const containerName = this.xmlName(String(container.$type)); + const childName = this.xmlName(String(element.$type)); + const added = containerName === 'zeebe:ioMapping' ? childName : `${containerName}/${childName}`; + const attributes = (element.$descriptor?.properties ?? []).filter((candidate) => candidate.isAttr && candidate.name !== property && typeof element[candidate.name] === 'string' && element[candidate.name] !== '').map((candidate) => this.quoted(`${candidate.name}=${String(element[candidate.name])}`)); + const fixed = this.quoted(`${property}=${PLACEHOLDERS[property] ?? `<${property}>`}`); + return `Rebuild it: \`bpmn ext remove ${address.target} ${this.quoted(`${containerName}/${childName}[${position}]`)}\`, then \`bpmn ext add ${address.target} ${[this.quoted(`${address.slot}${added}`), ...attributes, fixed].join(' ')}\`.`; + } + + /** Several event definitions: keep the one the engine acts on (the rule names it as `kept`). */ + private keepOneTrigger(id: string, report: BpmnlintEngineReport): string { + const kept = report.data?.kept as ModdleNode | undefined; + const trigger = kept?.$type ? TRIGGERS[kept.$type] : undefined; + return `Keep one: \`bpmn set ${id} trigger=${trigger ?? ''}\` (the others are removed).`; + } + + /** `bpmn add` of a BPMN element a process or sub-process lacks (a start event). */ + private addChild(id: string, host: ModdleNode, requiredType: string): string { + const kind = requiredType === 'bpmn:Activity' ? 'serviceTask' : `${requiredType.charAt(5).toLowerCase()}${requiredType.slice(6)}`; + return host.$type === 'bpmn:Process' ? `\`bpmn add ${kind} "" --before \`.` : `\`bpmn add ${kind} "" --in ${id}\`.`; + } + + private extensionTypes(node: ModdleNode): string[] { + return (((node.extensionElements as ModdleNode | undefined)?.values as ModdleNode[] | undefined) ?? []).map((value) => this.xmlName(String(value.$type))); + } + + /** The element name of a type as the XML writes it: `zeebe:TaskDefinition` -> `zeebe:taskDefinition`. */ + private xmlName(type: string): string { + const colon = type.indexOf(':'); + return `${type.slice(0, colon + 1)}${type.charAt(colon + 1).toLowerCase()}${type.slice(colon + 2)}`; + } + + /** `` for `camunda:topic`, `` for `camunda:resultVariable`. */ + private placeholder(key: string, node?: ModdleNode): string { + const name = key.slice(Math.max(key.lastIndexOf(':'), key.lastIndexOf('.')) + 1); + if (name === 'name' && (node?.$type === 'bpmn:Message' || node?.$type === 'bpmn:Signal')) return `<${node.$type.slice(5)}Name>`; + return PLACEHOLDERS[name] ?? `<${name}>`; + } + + /** Quotes a shell argument when the shell would read it (a `` stays as it is). */ + private quoted(arg: string): string { + return /^[A-Za-z0-9_.:=,/@+-]*(<[a-zA-Z ]+>)?$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`; + } + + private fallback(id: string | undefined): string { + return id ? `Fix what the message names on ${id}: \`bpmn show ${id}\` shows the element.` : 'Fix what the message names in the XML.'; + } +} diff --git a/src/adapters/out/bpmnlint/engine/EngineRun.ts b/src/adapters/out/bpmnlint/engine/EngineRun.ts new file mode 100644 index 00000000..52a5fa4e --- /dev/null +++ b/src/adapters/out/bpmnlint/engine/EngineRun.ts @@ -0,0 +1,207 @@ +import type { BpmnModdle, ImportWarning } from 'bpmn-moddle'; +import type { Doc } from '../../../../domain/document/Doc.js'; +import type { ProfileFinding } from '../../../../domain/document/Finding.js'; +import type { EnginePlatformInfo } from '../../../../domain/validation/EngineLint.js'; +import type { BpmnlintConfig, BpmnlintEngineReport, BpmnlintLinterClass, BpmnlintResolver, BpmnlintRule } from '../bpmnlint-contract.js'; +import type { EngineKit } from '../engine-kit.js'; +import { CamundaBooleans } from './CamundaBooleans.js'; +import { CompatVerdicts } from './CompatVerdicts.js'; +import { EngineFindings } from './EngineFindings.js'; +import { PublishMessages } from './PublishMessages.js'; +import { SchemaText, type SchemaTextIssue } from './SchemaText.js'; +import { SchemaTextFindings } from './SchemaTextFindings.js'; +import { UnknownVendorElements } from './UnknownVendorElements.js'; +import { UnprefixedAttributes } from './UnprefixedAttributes.js'; + +type Engine = EnginePlatformInfo['platform']; + +/** One engine's rules and their config, as `rules/c7` and `rules/c8` build them. */ +export interface EngineEntry { + createResolver(): BpmnlintResolver; + lintConfig(version?: string): BpmnlintConfig; + /** What the rules need of the file that the parse for them no longer shows. */ + afterParse?(definitions: unknown, warnings: readonly ImportWarning[], vendor: { operaton: boolean; prefix: string }): void; +} + +const ENGINE_NAMES: Record = { c7: 'Camunda 7', c8: 'Camunda 8' }; +const EXECUTION_PLATFORMS: Record = { c7: 'Camunda Platform', c8: 'Camunda Cloud' }; + +/** + * Where bpmn-cli reads camunda-compat's severity differently: a user task needs no form to run, + * and a job worker user task works (the engines' layers would say runtime for both); Camunda 7.20+ + * refuses a process without a history time to live unless the engine sets a default. + * c8-feel-expression checks FEEL with the grammar Camunda 8 accepts, names and values without = too. + */ +const SEVERITY_OVERRIDES: Record> = { + c7: { 'camunda-compat/history-time-to-live': 'error' }, + c8: { + 'camunda-compat/feel': 'off', + 'camunda-compat/user-task-definition': 'off', + 'camunda-compat/zeebe-user-task': 'info', + }, +}; + +/** It reports a send task with a zeebe:publishMessage as one without an implementation. */ +const COMPAT_IMPLEMENTATION = 'camunda-compat/implementation'; +const C7_MULTI_INSTANCE = 'bpmn-cli/c7-multi-instance'; +/** Rules of camunda-compat's configs that do not skip the processes the engine does not run. */ +const EXECUTABLE_ONLY = ['start-event-required', 'camunda-compat/history-time-to-live']; + +/** + * One engine's rules on a document, loaded on demand: camunda-compat's config for the file's + * engine version and bpmn-cli's own rules, on the document parsed again + * with the engine's moddle extension. The checks a typed parse cannot see (unknown vendor + * elements, unprefixed attributes, publishMessage, the schema rules of the file's text) are + * bpmn-cli's own. + */ +export class EngineRun { + private readonly moddle: BpmnModdle; + /** read once per text: an edit lints before and after */ + private readonly textIssues = new WeakMap(); + + constructor( + private readonly kit: EngineKit, + private readonly engine: Engine, + private readonly entry: EngineEntry, + private readonly Linter: BpmnlintLinterClass, + ) { + this.moddle = new kit.BpmnModdle(entry.lintConfig().moddleExtensions as Record); + } + + async findings(doc: Doc, info: EnginePlatformInfo): Promise { + const engineConfig = this.entry.lintConfig(info.version); + const config = { ...engineConfig, rules: { ...engineConfig.rules, ...SEVERITY_OVERRIDES[this.engine] } }; + const { rootElement, elementsById, warnings } = await this.moddle.fromXML(this.lintedXml(await doc.toXml(), info, doc)); + this.entry.afterParse?.(rootElement, warnings, this.vendor(doc, info)); + (rootElement as unknown as { $attrs: Record }).$attrs['modeler:executionPlatform'] = EXECUTION_PLATFORMS[this.engine]; + const failures = new Map(); + const linter = new this.Linter({ config, resolver: this.entry.createResolver(), transformRule: (rule, { pkg, ruleName }) => this.guarded(rule, `${pkg}/${ruleName}`, failures) }); + const results = (await linter.lint(rootElement)) as Record; + for (const rule of Object.keys(results)) results[rule] = results[rule]!.filter((report) => report.category !== 'rule-error'); + this.withoutUnresolvedLoopInputs(results, warnings); + this.withoutNonExecutableProcesses(results, doc); + const published = this.engine === 'c8' ? new PublishMessages(this.kit, doc) : undefined; + const ownFindings = published ? published.findings() : new CamundaBooleans(this.kit, doc).findings(); + if (published) { + this.withoutPublishedImplementations(results, published); + new CompatVerdicts(elementsById, info.version).apply(results); + } + const unprefixed = new UnprefixedAttributes(this.kit, doc, this.engine).findings(); + const schemaKeys = new Set(unprefixed.map((finding) => `${finding.element}|${finding.subject!.replace(/^schema:/, 'attr:')}`)); + const lint = new EngineFindings(this.kit, this.engine, elementsById, (id, key) => this.spelling(doc, id, key), new this.kit.DocumentNamespaces(doc).prefixOf(this.kit.ZEEBE.uri) ?? 'zeebe').of(results); + const findings = [ + ...lint.filter((finding) => !(finding.code === 'W_C8_DEPLOY_SCHEMA' && schemaKeys.has(`${finding.element}|${finding.subject}`))), + ...unprefixed, + ...ownFindings, + ...[...failures].map(([rule, message]) => this.kit.makeFinding('practice', 'W_LINT_FAILED', `The ${ENGINE_NAMES[this.engine]} rule ${rule} failed: ${message}; its findings are left out`, undefined, { subject: `failed:${rule}`, hint: 'Report the file and the rule: it is a bug of the rule, not of the model.' })), + ...new UnknownVendorElements(this.kit, doc, this.engine).findings(), + ...(this.engine === 'c8' ? new SchemaTextFindings(this.kit, doc).findings(this.schemaTextIssues(doc)) : []), + ]; + return this.inDocumentOrder(doc, findings); + } + + /** Operaton reads its own namespace like the camunda one: the rules read the camunda properties. */ + private lintedXml(xml: string, info: EnginePlatformInfo, doc: Doc): string { + if (!info.operaton) return xml; + const camundaPrefix = new this.kit.DocumentNamespaces(doc).prefixOf(this.kit.CAMUNDA_URI); + const shadowed = camundaPrefix ? this.withoutCamundaElements(xml, camundaPrefix) : xml; + return shadowed.replaceAll(`"${this.kit.OPERATON_URI}"`, `"${this.kit.CAMUNDA_URI}"`); + } + + /** + * Operaton reads its own namespace: the rules read it as camunda's. In a file that declares both, + * the camunda extension elements go out of their sight (else one of each reads as doubled), while + * camunda attributes stay, which Operaton reads when the operaton one is missing. + */ + private withoutCamundaElements(xml: string, prefix: string): string { + const shadow = `${prefix}-read-after-operaton`; + return xml.replace(new RegExp(`<(/?)${prefix}:`, 'g'), `<$1${shadow}:`).replace(/<([\w-]+:)?definitions\b/, (start) => `${start} xmlns:${shadow}="${this.kit.CAMUNDA_URI}#read-after-operaton"`); + } + + /** The Camunda 7 vendor and the prefix of the content the rules read. */ + private vendor(doc: Doc, info: EnginePlatformInfo): { operaton: boolean; prefix: string } { + const uri = info.operaton ? this.kit.OPERATON_URI : this.kit.CAMUNDA_URI; + return { operaton: !!info.operaton, prefix: new this.kit.DocumentNamespaces(doc).prefixOf(uri) ?? 'camunda' }; + } + + /** The file's spelling of a vendor attribute the typed parse names with its package prefix. */ + private spelling(doc: Doc, id: string, key: string): string { + const local = key.slice(key.indexOf(':') + 1); + const ns = new this.kit.DocumentNamespaces(doc); + const written = Object.keys(doc.get(id)?.$attrs ?? {}).find((name) => { + const uri = ns.uriOfName(name); + return name.slice(name.indexOf(':') + 1) === local && uri !== undefined && this.kit.C7_URIS.has(uri); + }); + return written ?? key; + } + + /** Grouped by element in the order of the file, as `bpmn validate` lists the other findings. */ + private inDocumentOrder(doc: Doc, findings: ProfileFinding[]): ProfileFinding[] { + const position = new Map(); + for (const el of this.kit.walk(doc.definitions)) { + const id = (el as { id?: unknown }).id; + if (typeof id === 'string' && !position.has(id)) position.set(id, position.size); + } + const at = (finding: ProfileFinding): number => (finding.element !== undefined ? (position.get(finding.element) ?? -1) : -1); + return findings.map((finding, index) => ({ finding, index })).sort((left, right) => at(left.finding) - at(right.finding) || left.index - right.index).map(({ finding }) => finding); + } + + /** + * A loopDataInputRef may name a process variable Camunda 7 resolves, not an id of the file: the + * parse drops it, and c7-multi-instance then reads the loop as one without a collection. + */ + private withoutUnresolvedLoopInputs(results: Record, warnings: ImportWarning[]): void { + const reports = results[C7_MULTI_INSTANCE]; + if (!reports) return; + const loops = new Set(warnings.filter((warning) => warning.property === 'bpmn:loopDataInputRef').map((warning) => (warning.element as { $parent?: { id?: string } } | undefined)?.$parent?.id)); + results[C7_MULTI_INSTANCE] = reports.filter((report) => !loops.has(report.id)); + } + + /** The engines check executable processes only: a missing start event or history time to live elsewhere is no finding. */ + private withoutNonExecutableProcesses(results: Record, doc: Doc): void { + for (const rule of EXECUTABLE_ONLY) { + const reports = results[rule]; + if (!reports) continue; + results[rule] = reports.filter((report) => { + let el = report.id ? doc.get(report.id) : undefined; + while (el && !this.kit.is(el, 'bpmn:Process')) el = el.$parent as typeof el; + return !el || this.kit.peek(el, 'isExecutable') === true; + }); + } + } + + /** A zeebe:publishMessage (its findings read first) is the implementation the typed parse dropped. */ + private withoutPublishedImplementations(results: Record, published: PublishMessages): void { + const reports = results[COMPAT_IMPLEMENTATION]; + if (reports) results[COMPAT_IMPLEMENTATION] = reports.filter((report) => !report.id || !published.hosts.has(report.id)); + } + + /** bpmnlint would print a throwing rule to stderr; its findings are left out instead. */ + private guarded(rule: BpmnlintRule, name: string, failures: Map): BpmnlintRule { + const check = rule?.check; + if (typeof check !== 'function') return rule; + return { + ...rule, + check: (...args: unknown[]) => { + if (failures.has(name)) return false; + try { + return (check as (...checkArgs: unknown[]) => unknown)(...args); + } catch (err) { + failures.set(name, String((err as Error)?.message ?? err).split('\n')[0] ?? ''); + return false; + } + }, + }; + } + + private schemaTextIssues(doc: Doc): SchemaTextIssue[] { + const source = doc.source; + if (!source) return []; + let issues = this.textIssues.get(source); + if (!issues) { + issues = new SchemaText(this.kit).issues(source.text, doc.moddle); + this.textIssues.set(source, issues); + } + return issues; + } +} diff --git a/src/adapters/out/bpmnlint/engine/PublishMessages.ts b/src/adapters/out/bpmnlint/engine/PublishMessages.ts new file mode 100644 index 00000000..921e614c --- /dev/null +++ b/src/adapters/out/bpmnlint/engine/PublishMessages.ts @@ -0,0 +1,63 @@ +import type { Doc } from '../../../../domain/document/Doc.js'; +import type { ProfileFinding } from '../../../../domain/document/Finding.js'; + + +import type { El } from '../../../../domain/document/utils/moddle.js'; + + +import type { DocumentNamespaces } from '../../../../domain/document/vendor/DocumentNamespaces.js'; +import type { EngineKit } from '../engine-kit.js'; + +/** + * `zeebe:publishMessage`, which Camunda 8.9 checks but does not run. zeebe-bpmn-moddle does not + * define it, so a typed parse drops it: no bpmnlint rule sees it, and camunda-compat reports its + * send task as one without an implementation. bpmn-cli's model keeps it; `hosts` are the elements + * whose implementation findings it answers. + */ +export class PublishMessages { + readonly hosts = new Set(); + private readonly ns: DocumentNamespaces; + private readonly prefix: string; + + constructor(private readonly kit: EngineKit, private readonly doc: Doc) { + this.ns = new this.kit.DocumentNamespaces(doc); + this.prefix = this.ns.prefixOf(this.kit.ZEEBE.uri) ?? 'zeebe'; + } + + findings(): ProfileFinding[] { + const out: ProfileFinding[] = []; + for (const proc of this.doc.processes().filter((candidate) => this.kit.peek(candidate, 'isExecutable') === true)) { + for (const el of this.kit.walk(proc, { bpmnOnly: true })) { + if (this.kit.is(el, 'bpmn:SendTask')) this.check(el, el, out); + else if (this.kit.is(el, 'bpmn:IntermediateThrowEvent') || this.kit.is(el, 'bpmn:EndEvent')) { + const definition = this.kit.list(el, 'eventDefinitions').find((candidate) => this.kit.is(candidate, 'bpmn:MessageEventDefinition')); + if (definition) this.check(el, definition, out); + } + } + } + return out; + } + + private check(el: El, host: El, out: ProfileFinding[]): void { + const publish = this.zeebe(host, 'publishMessage')[0]; + if (!publish) return; + const id = this.kit.peek(el, 'id')!; + const label = `${this.kit.kindLabel(el).charAt(0).toLowerCase()}${this.kit.kindLabel(el).slice(1)} ${id}`; + const name = `${this.prefix}:publishMessage`; + const slot = host === el ? '' : 'definition.'; + this.hosts.add(id); + if (this.zeebe(el, 'taskDefinition').length) { + out.push(this.kit.makeFinding('deploy', 'W_C8_DEPLOY_IMPLEMENTATION', `${label} has both a ${this.prefix}:taskDefinition and a ${name}; Camunda 8 needs exactly one of them and refuses the file`, id, { subject: 'impl:both', hint: `Keep one: \`bpmn ext remove ${id} ${this.prefix}:taskDefinition\` (or \`bpmn ext remove ${id} ${slot}${name}\`).` })); + return; + } + if (!this.kit.peek(host, 'messageRef')) out.push(this.kit.makeFinding('deploy', 'W_C8_DEPLOY_MESSAGE', `${label} publishes no message (no message reference); Camunda 8 refuses the file`, id, { subject: 'publish:messageRef', hint: `\`bpmn set ${id} message=\`.` })); + const correlationKey = this.kit.peek(publish, 'correlationKey'); + if (correlationKey === undefined || correlationKey === '') out.push(this.kit.makeFinding('deploy', 'W_C8_DEPLOY_MESSAGE', `The ${name} of ${label} has no correlationKey; Camunda 8 refuses the file`, id, { subject: 'publish:correlationKey', hint: `\`bpmn ext add ${id} ${slot}${name} correlationKey==\`.` })); + const send = this.kit.is(el, 'bpmn:SendTask'); + out.push(this.kit.makeFinding('runtime', 'W_C8_UNSUPPORTED_IMPLEMENTATION', `${label} publishes its message with ${name}: Camunda 8.9 accepts the file but does not run it (${send ? 'the instance gets an incident: only job worker send tasks are supported' : 'the event completes without sending anything'})`, id, { subject: 'publishMessage', hint: `Use a job worker: \`bpmn ext remove ${id} ${slot}${name}\`, then \`bpmn ext add ${id} ${this.prefix}:taskDefinition type=\`.` })); + } + + private zeebe(el: El, local: string): El[] { + return this.kit.list(this.kit.peek(el, 'extensionElements'), 'values').filter((value) => this.ns.uriOfElement(value) === this.kit.ZEEBE.uri && value.$type.slice(value.$type.indexOf(':') + 1) === local); + } +} diff --git a/src/domain/validation/profiles/SchemaText.ts b/src/adapters/out/bpmnlint/engine/SchemaText.ts similarity index 95% rename from src/domain/validation/profiles/SchemaText.ts rename to src/adapters/out/bpmnlint/engine/SchemaText.ts index 4b7bf290..81734f1b 100644 --- a/src/domain/validation/profiles/SchemaText.ts +++ b/src/adapters/out/bpmnlint/engine/SchemaText.ts @@ -1,7 +1,8 @@ import type { BpmnModdle } from 'bpmn-moddle'; -import type { XElement } from '../../document/utils/text-reader.js'; + +import type { XElement } from '../../../../domain/document/utils/text-reader.js'; + import { BpmnSchema, BPMNDI_NS } from './BpmnSchema.js'; -import type { RuleKit } from './rule-kit.js'; const OPAQUE = new Set(['extensionElements', 'documentation']); @@ -22,10 +23,11 @@ const IDREF_CHILDREN: Array<[string, string[]]> = [ ]; const XSD_BOOLEAN = /^[ \t\r\n]*(?:true|false|1|0)[ \t\r\n]*$/; +import type { EngineKit } from '../engine-kit.js'; /** XML 1.0 fifth edition: what the schema's xsd:ID allows. */ const NCNAME_START = 'A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD\\u{10000}-\\u{EFFFF}'; -export const NCNAME = new RegExp(`^[${NCNAME_START}][${NCNAME_START}\\-.0-9\\u00B7\\u0300-\\u036F\\u203F-\\u2040]*$`, 'u'); +const NCNAME = new RegExp(`^[${NCNAME_START}][${NCNAME_START}\\-.0-9\\u00B7\\u0300-\\u036F\\u203F-\\u2040]*$`, 'u'); export interface SchemaTextIssue { kind: 'order' | 'idref' | 'id' | 'boolean'; @@ -46,7 +48,7 @@ export interface SchemaTextIssue { * but an engine that validates against the XSD refuses them (Camunda 8.9, engine-checked). */ export class SchemaText { - constructor(private readonly kit: RuleKit) {} + constructor(private readonly kit: EngineKit) {} /** * [] when the text cannot be read: the model is then all there is. An xsd:boolean attribute diff --git a/src/adapters/out/bpmnlint/engine/SchemaTextFindings.ts b/src/adapters/out/bpmnlint/engine/SchemaTextFindings.ts new file mode 100644 index 00000000..46d531ba --- /dev/null +++ b/src/adapters/out/bpmnlint/engine/SchemaTextFindings.ts @@ -0,0 +1,85 @@ +import type { Doc } from '../../../../domain/document/Doc.js'; +import type { ProfileFinding } from '../../../../domain/document/Finding.js'; + + +import type { El } from '../../../../domain/document/utils/moddle.js'; +import type { SchemaTextIssue } from './SchemaText.js'; + +const SCHEMA = 'W_C8_DEPLOY_SCHEMA'; +import type { EngineKit } from '../engine-kit.js'; + +/** + * The Camunda 8 schema findings only the file's text shows: child order, IDREFs that name no id, + * bad xsd:booleans and ids bpmn-moddle could not read. A bpmnlint rule sees the model only, so + * bpmn-cli reports them itself. An issue of an element an edit removed is gone. + */ +export class SchemaTextFindings { + constructor( + private readonly kit: EngineKit, + private readonly doc: Doc, + ) {} + + findings(issues: SchemaTextIssue[]): ProfileFinding[] { + const out: ProfileFinding[] = []; + for (const issue of issues) { + if (issue.kind === 'id') { + if (this.doc.get(issue.value!)) continue; + out.push(this.unreadableId(issue)); + continue; + } + const owner = issue.owner !== undefined ? this.doc.get(issue.owner) : undefined; + if (issue.owner !== undefined && !owner) continue; + if (issue.kind === 'boolean') out.push(this.badBoolean(issue, owner)); + else if (issue.kind === 'order') out.push(this.badOrder(issue, owner)); + else out.push(this.danglingIdref(issue, owner)); + } + return out; + } + + private unreadableId(issue: SchemaTextIssue): ProfileFinding { + const value = issue.value!; + const reason = value.includes(':') ? 'a colon is not allowed in an id' : 'letters, digits, _ . - and no leading digit'; + return this.finding(undefined, `id:${value}`, ` has the id "${value}", which is no XML NCName (${reason}); bpmn-moddle cannot read the element (an import warning) and Camunda 8 validates the file against the BPMN schema and refuses it`, `Rename it in the XML (and every reference to it), e.g. to ${value.replace(/[^A-Za-z0-9_.-]+/g, '_').replace(/^(?=[^A-Za-z_])/, '_')}.`); + } + + private badBoolean(issue: SchemaTextIssue, owner: El | undefined): ProfileFinding { + const what = this.nestedLabel(issue, owner, issue.element.startsWith('BPMN') ? 'bpmndi' : 'bpmn'); + return this.finding(owner, `boolean:${issue.element}:${issue.ref}`, `${this.capitalised(what)} has ${issue.ref}="${issue.value}", which is no xsd:boolean (true or false; bpmn-moddle reads it as false); Camunda 8 validates the file against the BPMN schema and refuses it`, `Write ${issue.ref}="true" or ${issue.ref}="false" in the XML of ${owner ? owner.id : `<${issue.element}>`}.`); + } + + private badOrder(issue: SchemaTextIssue, owner: El | undefined): ProfileFinding { + const what = this.nestedLabel(issue, owner, 'bpmn'); + return this.finding(owner, `order:${issue.element}/${issue.child}`, `In ${what}, comes after ; the BPMN schema puts ${issue.child} before ${issue.before}, and Camunda 8 validates the file against the schema and refuses it`, `Move before in the XML of ${owner ? owner.id : `<${issue.element}>`} (Camunda Modeler and \`bpmn\` write that order for the elements they create or change).`); + } + + private danglingIdref(issue: SchemaTextIssue, owner: El | undefined): ProfileFinding { + const where = owner ? this.describe(owner) : `<${issue.element}>`; + const ref = issue.attribute ? `${issue.ref}="${issue.value}"` : `${issue.value}`; + return this.finding(owner, `idref:${issue.ref}:${issue.value}`, `${this.capitalised(where)} has ${ref}, but the file has no element with the id ${issue.value}; Camunda 8 checks such references against the ids of the file (cvc-id.1) and refuses it`, `Remove ${ref} from ${issue.owner ?? ``} in the XML, or point it at an element of the file (bpmn-moddle drops the reference when it reads the file: \`bpmn\` refuses to write the file without --force, and a forced write leaves it out).`); + } + + /** The issue's element, as the child of its owner when it is not the owner itself. */ + private nestedLabel(issue: SchemaTextIssue, owner: El | undefined, prefix: string): string { + const where = owner ? this.describe(owner) : `<${issue.element}>`; + const ownElement = owner && issue.element === this.lowerFirst(owner.$type.replace(/^[^:]*:/, '')); + return owner && !ownElement ? `<${prefix}:${issue.element}> of ${where}` : where; + } + + /** `userTask Activity_X`, as the engine findings name an element. */ + private describe(el: El): string { + const kind = this.kit.isBpmnElement(el) ? this.lowerFirst(this.kit.kindLabel(el).replace(/^bpmn:/, '')) : el.$type; + return `${kind} ${el.id}`; + } + + private finding(owner: El | undefined, subject: string, message: string, hint: string): ProfileFinding { + return this.kit.makeFinding('deploy', SCHEMA, message, owner?.id ?? this.doc.definitions.id, { hint, subject }); + } + + private capitalised(text: string): string { + return `${text[0]!.toUpperCase()}${text.slice(1)}`; + } + + private lowerFirst(text: string): string { + return text.charAt(0).toLowerCase() + text.slice(1); + } +} diff --git a/src/adapters/out/bpmnlint/engine/UnknownVendorElements.ts b/src/adapters/out/bpmnlint/engine/UnknownVendorElements.ts new file mode 100644 index 00000000..2fa315c8 --- /dev/null +++ b/src/adapters/out/bpmnlint/engine/UnknownVendorElements.ts @@ -0,0 +1,194 @@ +import type { Doc } from '../../../../domain/document/Doc.js'; +import type { ProfileFinding } from '../../../../domain/document/Finding.js'; + + +import type { El } from '../../../../domain/document/utils/moddle.js'; +import type { CamundaElementType } from '../../../../domain/document/vendor/CamundaDescriptor.js'; + +import type { ZeebeElementType } from '../../../../domain/document/vendor/ZeebeDescriptor.js'; + +const ENGINE_NAME = { c7: 'the engines', c8: 'Camunda 8' }; +import type { DocumentNamespaces } from '../../../../domain/document/vendor/DocumentNamespaces.js'; +import type { EngineKit } from '../engine-kit.js'; + +/** + * Extension elements of the engine's own namespace that its descriptor does not know. bpmnlint + * parses with the engine's moddle extension, which drops them (an import warning without the + * element), so no bpmnlint rule sees them; bpmn-cli's model keeps them as generic elements. + * Like the engines, only executable processes count. + */ +export class UnknownVendorElements { + private readonly ns: DocumentNamespaces; + private readonly code: string; + private readonly prefix: string; + + constructor( + private readonly kit: EngineKit, + private readonly doc: Doc, + private readonly platform: 'c7' | 'c8', + ) { + this.ns = new this.kit.DocumentNamespaces(doc); + this.code = platform === 'c8' ? 'W_C8_UNKNOWN_ELEMENT' : 'W_C7_UNKNOWN_ELEMENT'; + this.prefix = platform === 'c8' ? (this.ns.prefixOf(this.kit.ZEEBE.uri) ?? 'zeebe') : (this.ns.prefixOf(this.kit.CAMUNDA.uri) ?? 'camunda'); + } + + findings(): ProfileFinding[] { + const out: ProfileFinding[] = []; + for (const proc of this.doc.processes().filter((candidate) => this.kit.peek(candidate, 'isExecutable') === true)) { + for (const el of this.kit.walk(proc, { bpmnOnly: true })) this.checkHost(el, out); + } + return out; + } + + private checkHost(host: El, out: ProfileFinding[]): void { + const values = this.kit.list(this.kit.peek(host, 'extensionElements'), 'values'); + values.forEach((value, index) => { + if (!this.isOwnGeneric(value)) return; + const local = this.localName(value.$type); + const known = this.typeOf(local); + if (known) { + const misplaced = this.platform === 'c8' ? this.misplaced(host, local, index) : this.looseParameter(host, value, local, index); + if (misplaced) out.push(misplaced); + else this.checkChildren(host, value, known, `${this.prefix}:${local}`, out); + return; + } + const guess = this.closest(local, this.typeNames()); + out.push(this.finding(host, `ext:${local}`, `${this.where(host)} has the unknown extension element ${this.prefix}:${local}${guess ? ` (did you mean ${this.prefix}:${guess}?)` : ''}; ${ENGINE_NAME[this.platform]} ignores it`, this.removeHint(host, index, guess))); + }); + } + + /** + * A known zeebe element the typed parse drops, so no rule sees where it sits: one that belongs + * inside a container (a loose zeebe:input), or zeebe:publishMessage where Camunda 8 does not read + * it (on an end event instead of its message event definition). + */ + private misplaced(host: El, local: string, index: number): ProfileFinding | undefined { + const id = this.kit.peek(host, 'id'); + const remove = id ? `\`bpmn ext remove ${id} ${index}\`` : 'remove it from the XML'; + const name = `${this.prefix}:${local}`; + if (this.kit.ZEEBE.nestedOnly(local)) { + const container = this.kit.ZEEBE.containersOf(local)[0]!; + const add = id ? `: \`bpmn ext add ${id} ${name} ...\`` : ''; + return this.misplacedFinding(host, local, `${name} sits directly in the extension elements of ${this.describe(host)}, but belongs inside ${this.prefix}:${this.localName(container)}; Camunda 8 ignores it`, `Remove it (${remove}) and add it again, \`bpmn ext add\` files it into its container${add}.`); + } + if (this.typedParseKnows(local) || this.kit.ZEEBE.allowedOn(local, host) !== false) return undefined; + const hosts = (this.kit.ZEEBE.type(local)?.allowedIn ?? []).filter((allowed) => allowed.startsWith('bpmn:')).map((allowed) => `${allowed.charAt(5).toLowerCase()}${allowed.slice(6)}`); + return this.misplacedFinding(host, local, `${name} on ${this.describe(host)} has no effect: Camunda 8 reads it on ${hosts.length > 5 ? `${hosts.slice(0, 5).join(', ')}, ...` : hosts.join(', ')} only`, `Remove it: ${remove}.`); + } + + /** A camunda:inputParameter or outputParameter outside camunda:inputOutput, which the typed parse drops. */ + private looseParameter(host: El, parameter: El, local: string, index: number): ProfileFinding | undefined { + if (local !== 'inputParameter' && local !== 'outputParameter') return undefined; + const { id, slot } = this.address(host); + const name = this.kit.peek(parameter, 'name') ?? (parameter.$attrs as Record | undefined)?.['name']; + const body = this.kit.peek(parameter, '$body'); + const add = id ? `: \`bpmn ext add ${id} ${slot}camunda:${local}${name ? ` name=${name}` : ''}${body ? ` --body ${body}` : ''}\`` : ''; + const remove = id ? `\`bpmn ext remove ${id} ${slot}${index}\`` : 'remove it from the XML'; + return this.kit.makeFinding('runtime', 'W_C7_MISPLACED_EXTENSION', `${this.prefix}:${local} sits directly in the extension elements of ${this.where(host)}, but belongs inside ${this.prefix}:inputOutput; the engines ignore it`, id, { hint: `Remove it (${remove}) and add it again, \`bpmn ext add\` files it into its container${add}.`, subject: `ext:${local}` }); + } + + /** + * How `bpmn` addresses the extension elements of `host`: an event definition or a loop through + * its flow node and slot (`W definition.`, `SP loop.`), any other element by its id. + */ + private address(host: El): { id: string | undefined; slot: string } { + const owner = host.$parent as El | undefined; + const ownerId = owner ? this.kit.peek(owner, 'id') : undefined; + if (ownerId && this.kit.is(host, 'bpmn:EventDefinition')) { + const definitions = this.kit.list(owner, 'eventDefinitions'); + return { id: ownerId, slot: definitions.length > 1 ? `definition[${definitions.indexOf(host)}].` : 'definition.' }; + } + if (ownerId && this.kit.is(host, 'bpmn:LoopCharacteristics')) return { id: ownerId, slot: 'loop.' }; + return { id: this.kit.peek(host, 'id'), slot: '' }; + } + + /** `timerEventDefinition Def_T`, or `the timerEventDefinition of intermediateCatchEvent:timer W` */ + private where(host: El): string { + const owner = host.$parent as El | undefined; + if (this.kit.peek(host, 'id') || !owner || !(this.kit.is(host, 'bpmn:EventDefinition') || this.kit.is(host, 'bpmn:LoopCharacteristics'))) return this.describe(host); + const local = this.localName(host.$type); + return `the ${local.charAt(0).toLowerCase()}${local.slice(1)} of ${this.describe(owner)}`; + } + + /** The rules see a placement of a type the engine's moddle extension knows; zeebe:publishMessage is bpmn-cli's own. */ + private typedParseKnows(local: string): boolean { + return (this.kit.ZEEBE_DESCRIPTOR.types as Array<{ name?: string }>).some((type) => type.name?.toLowerCase() === local.toLowerCase()); + } + + private misplacedFinding(host: El, local: string, message: string, hint: string): ProfileFinding { + return this.kit.makeFinding('runtime', 'W_C8_MISPLACED_EXTENSION', message, this.kit.peek(host, 'id'), { hint, subject: `ext:${local}` }); + } + + private checkChildren(host: El, parent: El, parentType: ZeebeElementType | CamundaElementType, path: string, out: ProfileFinding[]): void { + for (const child of this.kit.list(parent, '$children')) { + if (!this.isOwnGeneric(child)) continue; + const local = this.localName(child.$type); + if (this.isTextChild(parentType, local)) continue; + const known = this.typeOf(local); + if (known) { + this.checkChildren(host, child, known, `${path} > ${this.prefix}:${local}`, out); + continue; + } + out.push(this.finding(host, `ext:${path}/${local}`, `${this.where(host)} contains the unknown element ${this.prefix}:${local}${path ? ` (in ${path})` : ''}; ${ENGINE_NAME[this.platform]} ignores it`, this.rebuildHint(host, path.split(' > ')[0]!))); + } + } + + private isOwnGeneric(el: El): boolean { + const generic = (el?.$descriptor as { isGeneric?: boolean } | undefined)?.isGeneric; + const descriptor = this.platform === 'c8' ? this.kit.ZEEBE : this.kit.CAMUNDA; + return !!generic && descriptor.reads(this.ns.uriOfElement(el)); + } + + private typeOf(local: string): ZeebeElementType | CamundaElementType | undefined { + return this.platform === 'c8' ? this.kit.ZEEBE.type(local) : this.kit.CAMUNDA.type(local); + } + + private typeNames(): string[] { + return (this.platform === 'c8' ? this.kit.ZEEBE.typeNames() : this.kit.CAMUNDA.typeNames()).map((name) => this.localName(name)); + } + + /** Children a camunda type takes as plain text (connectorId, expression, string). */ + private isTextChild(parentType: ZeebeElementType | CamundaElementType, local: string): boolean { + return this.platform === 'c7' && (parentType as CamundaElementType).properties.some((property) => property.name === local && !property.isAttr && !property.isBody && !property.type.includes(':')); + } + + private removeHint(host: El, index: number, guess: string | undefined): string { + const { id, slot } = this.address(host); + if (!id) return 'Fix it in the XML.'; + return `Remove it (\`bpmn ext remove ${id} ${slot}${index}\`)${guess ? ` and add the right one: \`bpmn ext add ${id} ${slot}${this.prefix}:${guess} ...\`` : ''}.`; + } + + private rebuildHint(host: El, type: string): string { + const { id, slot } = this.address(host); + if (!id) return 'Fix it in the XML.'; + return `See it with \`bpmn ext list ${id} --json\`, then rebuild it: \`bpmn ext add ${id} ${slot}${type} --replace --xml '<${type} ...>...'\`.`; + } + + /** The closest name within a small edit distance. */ + private closest(name: string, candidates: string[]): string | undefined { + const limit = Math.max(1, Math.floor(name.length / 4)); + let best: { candidate: string; distance: number } | undefined; + for (const candidate of candidates) { + const distance = this.kit.editDistance(name.toLowerCase(), candidate.toLowerCase()); + if (distance <= limit && (!best || distance < best.distance)) best = { candidate, distance }; + } + return best?.candidate; + } + + private describe(el: El): string { + const id = this.kit.peek(el, 'id'); + if (!id) return el.$type; + const kind = this.kit.isBpmnElement(el) ? this.kit.kindLabel(el).replace(/^bpmn:/, '') : this.localName(el.$type); + return `${kind.charAt(0).toLowerCase()}${kind.slice(1)} ${id}`; + } + + private finding(host: El, subject: string, message: string, hint: string): ProfileFinding { + let reported: El | undefined = this.address(host).id === this.kit.peek(host, 'id') ? host : (host.$parent as El | undefined); + while (reported && !this.kit.peek(reported, 'id')) reported = reported.$parent as El | undefined; + return this.kit.makeFinding('runtime', this.code, message, reported ? this.kit.peek(reported, 'id') : undefined, { hint, subject }); + } + + private localName(type: string): string { + return type.slice(type.indexOf(':') + 1); + } +} diff --git a/src/adapters/out/bpmnlint/engine/UnprefixedAttributes.ts b/src/adapters/out/bpmnlint/engine/UnprefixedAttributes.ts new file mode 100644 index 00000000..43b6b75e --- /dev/null +++ b/src/adapters/out/bpmnlint/engine/UnprefixedAttributes.ts @@ -0,0 +1,102 @@ +import type { Doc } from '../../../../domain/document/Doc.js'; +import type { ProfileFinding } from '../../../../domain/document/Finding.js'; + + +import type { El } from '../../../../domain/document/utils/moddle.js'; + + + +import type { DocumentNamespaces } from '../../../../domain/document/vendor/DocumentNamespaces.js'; +import type { EngineKit } from '../engine-kit.js'; + +/** How `bpmn set` addresses an element nested in a flow node or flow. */ +const SLOTS: Array<[string, string]> = [['eventDefinitions', 'definition'], ['loopCharacteristics', 'loop'], ['conditionExpression', 'condition']]; + +/** + * Attributes without a namespace prefix that the BPMN schema does not define, which both engines + * refuse (cvc-complex-type.3.2.2). A typed parse reads some of them as the vendor attribute of + * that name (`assignee` on a user task as `camunda:assignee`), so a bpmnlint rule cannot see + * them: bpmn-cli reads its own model, where they stay what the file says. + */ +export class UnprefixedAttributes { + private readonly ns: DocumentNamespaces; + private readonly code: string; + private readonly prefix: string; + + constructor( + private readonly kit: EngineKit, + private readonly doc: Doc, + private readonly platform: 'c7' | 'c8', + ) { + this.ns = new this.kit.DocumentNamespaces(doc); + this.code = platform === 'c8' ? 'W_C8_DEPLOY_SCHEMA' : 'W_C7_DEPLOY_SCHEMA'; + this.prefix = platform === 'c8' ? 'zeebe' : (this.ns.prefixOf(this.kit.CAMUNDA.uri) ?? 'camunda'); + } + + findings(): ProfileFinding[] { + const out: ProfileFinding[] = []; + for (const el of this.kit.walk(this.doc.definitions, { bpmnOnly: true })) { + const keys = Object.keys(el.$attrs ?? {}).filter((key) => !key.includes(':') && key !== 'xmlns'); + if (!keys.length) continue; + const parent = el.$parent as El | undefined; + const slot = parent && this.kit.peek(parent, 'id') ? this.slotOf(el, parent) : undefined; + let owner: El | undefined = slot ? parent : el; + while (owner && !this.kit.peek(owner, 'id')) owner = owner.$parent as El | undefined; + const id = owner ? this.kit.peek(owner, 'id') : undefined; + const di = this.kit.isDiElement(el) || (!!owner && this.kit.isDiElement(owner)); + for (const key of keys) out.push(this.finding(el, owner, id, di ? undefined : slot ?? (el === owner ? '' : undefined), di, key)); + } + return out; + } + + private finding(el: El, owner: El | undefined, id: string | undefined, slot: string | undefined, di: boolean, key: string): ProfileFinding { + const own = el === owner; + const vendor = this.platform === 'c7' ? this.kit.CAMUNDA.attr(key) : undefined; + const fits = !!vendor && own && !!id && vendor.owners.some((ownerType) => this.kit.is(el, ownerType)); + const where = own ? this.describe(el) : `${el.$type}${id ? ` in ${id}` : ''}`; + const remove = id && slot !== undefined ? `\`bpmn set ${id} ${this.quoted(`${slot}${key}=`)}\`` : undefined; + const engines = this.platform === 'c8' ? 'Camunda 8 validates the file against the schema and refuses it' : 'the engines validate the file against the schema and refuse it'; + const message = `${where} has the attribute ${key}, which the BPMN schema does not define${fits ? ` (did you mean ${this.prefix}:${key}?)` : ''}; ${engines}`; + return this.kit.makeFinding('deploy', this.code, message, id ?? this.kit.peek(this.doc.definitions, 'id'), { subject: `schema:${key}`, hint: this.hint(el, id, key, fits, remove, di) }); + } + + private hint(el: El, id: string | undefined, key: string, fits: boolean, remove: string | undefined, di: boolean): string { + const link = id && this.kit.is(el, 'bpmn:BusinessRuleTask') && (key === 'calledDecision' || key === 'calledElement') && !this.implemented(el) ? new this.kit.DecisionLinkReader(this.doc).linkOf(el) : undefined; + if (link) { + const command = `\`bpmn set ${id} ${this.quoted(`calledDecision=${link.value}`)}\``; + return this.platform === 'c7' ? `Write the decision link in the engines' spelling: ${command} (${this.prefix}:decisionRef; the unprefixed ${key} is removed).` : `Write the decision link the Camunda 8 way: ${command} (${this.prefix}:calledDecision; the unprefixed ${key} is removed; give it a resultVariable).`; + } + if (fits) return `Use the vendor attribute instead: \`bpmn set ${id} ${this.quoted(`${key}=`)} ${this.quoted(`${this.prefix}:${key}=${String(el.$attrs[key] ?? '')}`)}\`.`; + const prefixed = this.platform === 'c8' ? 'vendor attributes need their namespace prefix; Camunda 8 settings are mostly zeebe: extension elements' : `vendor attributes need their namespace prefix, e.g. ${this.prefix}:`; + if (remove) return `Remove it: ${remove} (${prefixed}).`; + if (di) return this.platform === 'c8' ? `Remove ${key}="..." from the XML of ${id ?? 'the diagram element'}, or redraw the diagram: \`bpmn layout \` (the hand-made layout is lost).` : `Remove ${key}="..." from the XML of ${id ?? 'the diagram element'}, or redraw the diagram, which writes new diagram elements: \`bpmn layout \` (the hand-made layout is lost).`; + return this.platform === 'c8' ? `Remove ${key}="..." from the XML${id ? ` of ${id}` : ''}.` : `Remove ${key}="..." from the XML${id ? ` of ${id}` : ''} (${prefixed}).`; + } + + /** Another implementation stays, so the unprefixed attribute is no decision link. */ + private implemented(el: El): boolean { + return Object.keys(el.$attrs ?? {}).some((name) => /^[\w.-]+:(class|delegateExpression|expression|type)$/.test(name) && this.kit.CAMUNDA.reads(this.ns.uriOfName(name))); + } + + /** `definition.`, `definition[1].`, `loop.` or `condition.` of a nested element. */ + private slotOf(el: El, parent: El): string | undefined { + for (const [property, slot] of SLOTS) { + const value = this.kit.peek(parent, property); + if (value === el) return `${slot}.`; + if (Array.isArray(value) && value.includes(el)) return value.length > 1 ? `${slot}[${value.indexOf(el)}].` : `${slot}.`; + } + return undefined; + } + + /** Quotes a shell argument when the shell would read it. */ + private quoted(arg: string): string { + return /^[A-Za-z0-9_.:=,/@+-]*(<[a-zA-Z]+>)?$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`; + } + + private describe(el: El): string { + const kind = this.kit.isBpmnElement(el) ? this.kit.kindLabel(el).replace(/^bpmn:/, '') : el.$type; + const id = this.kit.peek(el, 'id'); + const name = `${kind.charAt(0).toLowerCase()}${kind.slice(1)}`; + return id ? `${name} ${id}` : name; + } +} diff --git a/src/adapters/out/bpmnlint/engine/rules/CamundaRules.ts b/src/adapters/out/bpmnlint/engine/rules/CamundaRules.ts new file mode 100644 index 00000000..c8648ae2 --- /dev/null +++ b/src/adapters/out/bpmnlint/engine/rules/CamundaRules.ts @@ -0,0 +1,16 @@ +import { EngineRule, type Engine } from '@miragon/bpmnlint-plugin-rules/engine'; + +export { defineRule, type ModdleElement, type Reporter, type ReportPath } from '@miragon/bpmnlint-plugin-rules/engine'; +export { ERROR_TYPES } from 'bpmnlint-plugin-camunda-compat/rules/utils/error-types.js'; +export { reportErrors } from 'bpmnlint-plugin-camunda-compat/rules/utils/reporter.js'; +export { describeElement, triggerName } from './describe.js'; + +/** A rule that checks the diagrams targeting Camunda 7. */ +export abstract class C7Rule extends EngineRule { + protected readonly engine: Engine = 'c7'; +} + +/** A rule that checks the diagrams targeting Camunda 8. */ +export abstract class C8Rule extends EngineRule { + protected readonly engine: Engine = 'c8'; +} diff --git a/src/adapters/out/bpmnlint/engine/rules/CompensationHandlerBoundary.ts b/src/adapters/out/bpmnlint/engine/rules/CompensationHandlerBoundary.ts new file mode 100644 index 00000000..5ae97394 --- /dev/null +++ b/src/adapters/out/bpmnlint/engine/rules/CompensationHandlerBoundary.ts @@ -0,0 +1,30 @@ +import { is } from 'bpmnlint-utils'; + +import { ERROR_TYPES, reportErrors, type ModdleElement, type Reporter } from './CamundaRules.js'; + +/** A boundary event attached to a compensation handler, which neither Camunda engine deploys. */ +export class CompensationHandlerBoundary { + /** Reports `node` when it is such a boundary event; `refusal` says who refuses it (`Camunda 8 refuses`). */ + static check(node: ModdleElement, reporter: Reporter, refusal: string): void { + if (!is(node, 'bpmn:BoundaryEvent')) { + return; + } + + const host = node.get('attachedToRef'); + if (host?.get('isForCompensation') !== true) { + return; + } + + reportErrors(node, reporter, { + message: `Boundary event ${node.id} is attached to the compensation handler ${host.id}; ${refusal} boundary events on compensation handlers`, + path: ['attachedToRef'], + data: { + type: ERROR_TYPES.ATTACHED_TO_REF_ELEMENT_TYPE_NOT_ALLOWED, + node, + parentNode: null, + attachedToRef: host, + hint: `Remove it: \`bpmn remove ${node.id}\` (or attach it to a normal activity: \`bpmn move ${node.id} --on \`).`, + }, + }); + } +} diff --git a/src/adapters/out/bpmnlint/engine/rules/DiagramNamespaces.ts b/src/adapters/out/bpmnlint/engine/rules/DiagramNamespaces.ts new file mode 100644 index 00000000..85831fc7 --- /dev/null +++ b/src/adapters/out/bpmnlint/engine/rules/DiagramNamespaces.ts @@ -0,0 +1,56 @@ +import { is } from 'bpmnlint-utils'; + +import type { ModdleElement } from '@miragon/bpmnlint-plugin-rules/engine'; + +/** + * The prefixes a typed parse writes for the engine packages, whatever the file spells: an unknown + * attribute of `xmlns:z="...zeebe/1.0"` lands in `$attrs` as `zeebe:foo`. + */ +const PACKAGE_URIS: Record = { + zeebe: 'http://camunda.org/schema/zeebe/1.0', + camunda: 'http://camunda.org/schema/1.0/bpmn', +}; + +/** + * The namespace prefixes a diagram declares, so a rule can tell which namespace an attribute the + * moddle left in `$attrs` belongs to: a typed parse keeps unknown attributes there under the + * prefix the file spells, which says nothing on its own. + */ +export class DiagramNamespaces { + private static readonly byDefinitions = new WeakMap(); + + private readonly uriByPrefix = new Map(); + + private constructor(definitions: ModdleElement) { + for (const [name, uri] of Object.entries(definitions?.$attrs ?? {})) { + if (name.startsWith('xmlns:') && typeof uri === 'string') { + this.uriByPrefix.set(name.slice('xmlns:'.length), uri); + } + } + } + + /** The namespaces of the diagram `node` belongs to. */ + static forElement(node: ModdleElement): DiagramNamespaces { + let definitions = node; + while (definitions && !is(definitions, 'bpmn:Definitions')) { + definitions = definitions.$parent; + } + + let namespaces = DiagramNamespaces.byDefinitions.get(definitions ?? DiagramNamespaces); + if (!namespaces) { + namespaces = new DiagramNamespaces(definitions); + DiagramNamespaces.byDefinitions.set(definitions ?? DiagramNamespaces, namespaces); + } + return namespaces; + } + + /** The URI of a prefixed name (`zeebe:foo`), or of the prefix of a type (`zeebe:TaskDefinition`). */ + uriOf(prefixedName: string): string | undefined { + const colon = prefixedName.indexOf(':'); + if (colon === -1) { + return undefined; + } + const prefix = prefixedName.slice(0, colon); + return this.uriByPrefix.get(prefix) ?? PACKAGE_URIS[prefix]; + } +} diff --git a/src/adapters/out/bpmnlint/engine/rules/LinkEvents.ts b/src/adapters/out/bpmnlint/engine/rules/LinkEvents.ts new file mode 100644 index 00000000..7413765d --- /dev/null +++ b/src/adapters/out/bpmnlint/engine/rules/LinkEvents.ts @@ -0,0 +1,73 @@ +import { is } from 'bpmnlint-utils'; + +import type { ModdleElement } from '@miragon/bpmnlint-plugin-rules/engine'; + +/** A link throw event and the (sub-)process it throws in. */ +export interface LinkThrow { + el: ModdleElement; + name: string; + scope: ModdleElement; +} + +/** A link catch event whose name an earlier catch event of the process already has. */ +export interface DuplicateLinkCatch { + first: ModdleElement; + el: ModdleElement; + name: string; +} + +/** + * The link events of a process, as both engines resolve them: a throw jumps to the catch of its + * name in its own (sub-)process, and catch names are unique in the process. + */ +export class LinkEvents { + private readonly firstCatch = new Map(); + private readonly caughtIn = new Map>(); + private readonly throws: LinkThrow[] = []; + private readonly duplicates: DuplicateLinkCatch[] = []; + + /** `linkName`: the link name a node throws or catches, `undefined` for any other node. */ + constructor(process: ModdleElement, linkName: (node: ModdleElement) => string | undefined) { + this.collect(process, linkName); + } + + /** The second and later catch events of a name, in document order. */ + duplicateCatches(): DuplicateLinkCatch[] { + return this.duplicates; + } + + /** The throw events whose (sub-)process has no catch event of their name. */ + unmatchedThrows(): LinkThrow[] { + return this.throws.filter( + (linkThrow) => !this.caughtIn.get(linkThrow.scope)?.has(linkThrow.name), + ); + } + + /** The first catch event of a name, wherever it is in the process. */ + catchNamed(name: string): ModdleElement | undefined { + return this.firstCatch.get(name); + } + + private collect( + scope: ModdleElement, + linkName: (node: ModdleElement) => string | undefined, + ): void { + for (const node of scope.flowElements ?? []) { + const name = linkName(node); + if (name !== undefined && is(node, 'bpmn:IntermediateCatchEvent')) { + const first = this.firstCatch.get(name); + if (first) { + this.duplicates.push({ first, el: node, name }); + } else { + this.firstCatch.set(name, node); + } + this.caughtIn.set(scope, new Set([...(this.caughtIn.get(scope) ?? []), name])); + } else if (name !== undefined && is(node, 'bpmn:IntermediateThrowEvent')) { + this.throws.push({ el: node, name, scope }); + } + if (node.flowElements) { + this.collect(node, linkName); + } + } + } +} diff --git a/src/adapters/out/bpmnlint/engine/rules/c7/C7Diagram.ts b/src/adapters/out/bpmnlint/engine/rules/c7/C7Diagram.ts new file mode 100644 index 00000000..1865be22 --- /dev/null +++ b/src/adapters/out/bpmnlint/engine/rules/c7/C7Diagram.ts @@ -0,0 +1,46 @@ +import { is } from 'bpmnlint-utils'; + +import type { ModdleElement } from '@miragon/bpmnlint-plugin-rules/engine'; +import { UnresolvedReferences } from './extensions/UnresolvedReferences.js'; + +const CAMUNDA_URI = 'http://camunda.org/schema/1.0/bpmn'; +const OPERATON = 'operaton'; + +/** What the engine run knows of a file that its parse for the rules no longer shows. */ +export interface C7DiagramFacts { + /** the file uses Operaton's namespace, which the run reads as the camunda one */ + operaton: boolean; + /** the prefix the file writes camunda content with (`camunda`, `operaton`, ...) */ + prefix: string; +} + +/** + * The Camunda 7 vendor of a diagram and the prefix its messages and hints write. The engine run + * records both before it lints, with the references the parse dropped; without that record (a rule + * spec), they are read from the namespaces: an `operaton` prefix bound to the camunda URI is + * Operaton's. + */ +export class C7Diagram { + private static readonly recorded = new WeakMap(); + + static record(definitions: ModdleElement, facts: C7DiagramFacts, warnings: readonly object[]): void { + C7Diagram.recorded.set(definitions, facts); + UnresolvedReferences.record(warnings); + } + + static of(node: ModdleElement): C7DiagramFacts { + let definitions = node; + while (definitions && !is(definitions, 'bpmn:Definitions')) { + definitions = definitions.$parent; + } + return (definitions && C7Diagram.recorded.get(definitions)) ?? C7Diagram.declared(definitions); + } + + private static declared(definitions: ModdleElement | undefined): C7DiagramFacts { + const prefixes = Object.entries(definitions?.$attrs ?? {}) + .filter(([name, uri]) => name.startsWith('xmlns:') && uri === CAMUNDA_URI) + .map(([name]) => name.slice('xmlns:'.length)); + const operaton = prefixes.includes(OPERATON); + return { operaton, prefix: operaton ? OPERATON : (prefixes[0] ?? 'camunda') }; + } +} diff --git a/src/adapters/out/bpmnlint/engine/rules/c7/c7-ad-hoc-sub-process.ts b/src/adapters/out/bpmnlint/engine/rules/c7/c7-ad-hoc-sub-process.ts new file mode 100644 index 00000000..8c885db5 --- /dev/null +++ b/src/adapters/out/bpmnlint/engine/rules/c7/c7-ad-hoc-sub-process.ts @@ -0,0 +1,89 @@ +import { is } from 'bpmnlint-utils'; +import { defineRule, ERROR_TYPES, reportErrors, type ModdleElement, type Reporter } from '../CamundaRules.js'; + +import { C7FlowGraph } from './flows/C7FlowGraph.js'; +import { C7FlowRule } from './flows/C7FlowRule.js'; +import { C7Diagram } from './C7Diagram.js'; + +/** + * Reports an ad-hoc sub-process with a sequence flow, a boundary event or a multi-instance loop: + * Camunda 7 does not run ad-hoc sub-processes and refuses the file. One that nothing connects + * deploys and is skipped with its content; Operaton deploys one with a valid loop too. + */ +class C7AdHocSubProcess extends C7FlowRule { + protected checkRunElement(node: ModdleElement, reporter: Reporter): void { + if (!is(node, 'bpmn:AdHocSubProcess')) { + return; + } + const graph = C7FlowGraph.forElement(node); + const incoming = graph.incoming(node); + const flows = [...incoming, ...graph.outgoing(node)]; + const boundaryEvents = graph.boundaryEvents(node); + if (flows.length || boundaryEvents.length) { + this.reportConnected(node, reporter, flows, incoming.length > 0, boundaryEvents); + } else if (this.isMultiInstance(node) && !C7Diagram.of(node).operaton) { + reportErrors(node, reporter, { + message: `Ad-hoc sub-process ${node.id} has a multi-instance loop; the engines do not run ad-hoc sub-processes, and Camunda 7 and CIB seven refuse the file (ENGINE-01009, "activity" is null)`, + path: ['loopCharacteristics'], + data: { + type: ERROR_TYPES.PROPERTY_NOT_ALLOWED, + node, + parentNode: null, + property: 'loopCharacteristics', + hint: `${this.retypeHint(node)}, or drop the loop: \`bpmn set ${node.id} loop=none\`.`, + }, + }); + } + } + + private reportConnected( + node: ModdleElement, + reporter: Reporter, + flows: ModdleElement[], + hasIncoming: boolean, + boundaryEvents: ModdleElement[], + ): void { + const parts = [ + this.listed('sequence flow', flows), + this.listed('boundary event', boundaryEvents), + ].filter(Boolean); + const refusal = flows.length + ? `invalid ${hasIncoming ? 'destination' : 'source'} '${node.id}' of sequence flow ${flows[0].id}` + : 'invalid reference in boundary event'; + reportErrors(node, reporter, { + message: `Ad-hoc sub-process ${node.id} is connected (${parts.join(' and ')}); the engines do not run ad-hoc sub-processes and refuse the file (${refusal})`, + path: null, + data: { + type: ERROR_TYPES.ELEMENT_TYPE_NOT_ALLOWED, + node, + parentNode: null, + flows, + boundaryEvents, + hint: `${this.retypeHint(node)}.`, + }, + }); + } + + private retypeHint(node: ModdleElement): string { + return `Make it an embedded sub-process: \`bpmn retype ${node.id} subProcess\`, then give its content a start event and flows (\`bpmn add startEvent "" --in ${node.id}\`, \`bpmn connect \`)`; + } + + private isMultiInstance(node: ModdleElement): boolean { + const loop = node.loopCharacteristics; + return !!loop && is(loop, 'bpmn:MultiInstanceLoopCharacteristics'); + } + + /** `sequence flows F1, F2`, or '' for none. */ + private listed(kind: string, elements: ModdleElement[]): string { + if (!elements.length) { + return ''; + } + const ids = elements.map((element) => element.id).join(', '); + return `${kind}${elements.length > 1 ? 's' : ''} ${ids}`; + } +} + +export default defineRule( + { ruleName: 'c7-ad-hoc-sub-process', kind: 'engine-specific', targetEngine: 'c7', impact: 'deploy' }, + C7AdHocSubProcess, +); diff --git a/src/adapters/out/bpmnlint/engine/rules/c7/c7-async-duplicate-extension.ts b/src/adapters/out/bpmnlint/engine/rules/c7/c7-async-duplicate-extension.ts new file mode 100644 index 00000000..59e08bcb --- /dev/null +++ b/src/adapters/out/bpmnlint/engine/rules/c7/c7-async-duplicate-extension.ts @@ -0,0 +1,20 @@ +import { defineRule } from '../CamundaRules.js'; + +import { CamundaDuplicatesRule, type DuplicatedExtension } from './extensions/CamundaDuplicatesRule.js'; + +/** + * Reports a second camunda:failedJobRetryTimeCycle on an element that does not run + * asynchronously yet: it deploys now, and the Camunda 7 engines refuse the file once it does. + */ +class C7AsyncDuplicateExtension extends CamundaDuplicatesRule { + protected readonly reportsRead = false; + + protected messageOf({ host, typeName, count }: DuplicatedExtension): string { + return `${host.describe()} has ${count} ${typeName} elements; once it runs asynchronously the engines refuse the file`; + } +} + +export default defineRule( + { ruleName: 'c7-async-duplicate-extension', kind: 'engine-specific', targetEngine: 'c7', impact: 'practice' }, + C7AsyncDuplicateExtension, +); diff --git a/src/adapters/out/bpmnlint/engine/rules/c7/c7-attribute-value.ts b/src/adapters/out/bpmnlint/engine/rules/c7/c7-attribute-value.ts new file mode 100644 index 00000000..c3c3587a --- /dev/null +++ b/src/adapters/out/bpmnlint/engine/rules/c7/c7-attribute-value.ts @@ -0,0 +1,105 @@ +import { is, isAny } from 'bpmnlint-utils'; +import { defineRule, describeElement, ERROR_TYPES, reportErrors, type ModdleElement, type Reporter, type ReportPath } from '../CamundaRules.js'; + +import { C7ExecutableRule } from './implementation/C7ExecutableRule.js'; +import { CamundaAttributes } from './implementation/CamundaAttributes.js'; +import { ImplementationHost } from './implementation/ImplementationHost.js'; +import { SetCommand } from './implementation/SetCommand.js'; +import { isExpression, isInteger } from './implementation/utils/values.js'; +import { C7Diagram } from './C7Diagram.js'; + +const DECISION_MAPPERS = ['singleEntry', 'singleResult', 'collectEntries', 'resultList']; +const JOB_PRIORITIZED = ['bpmn:Activity', 'bpmn:Gateway', 'bpmn:Event']; + +/** + * Reports a job or external task priority that is neither a number nor an expression, and a + * `camunda:mapDecisionResult` the engines do not know: Camunda 7 refuses the file. + */ +class C7AttributeValue extends C7ExecutableRule { + protected checkExecutable(node: ModdleElement, reporter: Reporter): void { + const command = new SetCommand(node.id); + if (is(node, 'bpmn:Process')) { + this.checkPriority(node, node, [], command, 'jobPriority', reporter); + this.checkPriority(node, node, [], command, 'taskPriority', reporter); + } + if (isAny(node, JOB_PRIORITIZED) && !is(node, 'bpmn:AdHocSubProcess')) { + this.checkPriority(node, node, [], command, 'jobPriority', reporter); + this.checkLoopPriority(node, reporter); + } + for (const host of ImplementationHost.forElement(node)) { + if (host.isExternal) { + this.checkPriority(host.element, node, host.path, host.command, 'taskPriority', reporter); + } + } + if (is(node, 'bpmn:BusinessRuleTask')) { + this.checkDecisionMapper(node, reporter); + } + } + + private checkLoopPriority(activity: ModdleElement, reporter: Reporter): void { + const loop = activity.get('loopCharacteristics'); + if (loop && is(loop, 'bpmn:MultiInstanceLoopCharacteristics')) { + const command = new SetCommand(activity.id, 'loop.'); + this.checkPriority(loop, activity, ['loopCharacteristics'], command, 'jobPriority', reporter); + } + } + + /** `element` carries the priority; `reported` is the element it sits on, or the element itself. */ + private checkPriority( + element: ModdleElement, + reported: ModdleElement, + path: ReportPath, + command: SetCommand, + name: string, + reporter: Reporter, + ): void { + const value = new CamundaAttributes(element).defined(name); + if (value === undefined || isInteger(value) || isExpression(value)) { + return; + } + const owner = element === reported ? undefined : reported; + const prefix = C7Diagram.of(reported).prefix; + reportErrors(reported, reporter, { + message: `${prefix}:${name}="${value}" on ${describeElement(element, owner)} is not a number or expression; the engines refuse it`, + path: [...path, name], + data: { + type: ERROR_TYPES.PROPERTY_VALUE_NOT_ALLOWED, + node: element, + parentNode: owner ?? null, + property: name, + hint: reported.id + ? `${command.of(`${prefix}:${name}=50`)} (an integer or \${...}).` + : 'Use an integer.', + }, + }); + } + + private checkDecisionMapper(node: ModdleElement, reporter: Reporter): void { + const attributes = new CamundaAttributes(node); + const mapper = attributes.defined('mapDecisionResult'); + if ( + attributes.defined('decisionRef') === undefined || + mapper === undefined || + DECISION_MAPPERS.includes(mapper) + ) { + return; + } + const prefix = C7Diagram.of(node).prefix; + reportErrors(node, reporter, { + message: `${prefix}:mapDecisionResult="${mapper}" on ${describeElement(node)} is not one of ${DECISION_MAPPERS.join(', ')}; the engines refuse the file`, + path: ['mapDecisionResult'], + data: { + type: ERROR_TYPES.PROPERTY_VALUE_NOT_ALLOWED, + node, + parentNode: null, + property: 'mapDecisionResult', + hint: `${new SetCommand(node.id).of(`${prefix}:mapDecisionResult=singleEntry`)} (or another of the four).`, + }, + }); + } +} + +export default defineRule( + { ruleName: 'c7-attribute-value', kind: 'engine-specific', targetEngine: 'c7', impact: 'deploy' }, + C7AttributeValue, +); diff --git a/src/adapters/out/bpmnlint/engine/rules/c7/c7-bad-value.ts b/src/adapters/out/bpmnlint/engine/rules/c7/c7-bad-value.ts new file mode 100644 index 00000000..3a2bcf5a --- /dev/null +++ b/src/adapters/out/bpmnlint/engine/rules/c7/c7-bad-value.ts @@ -0,0 +1,143 @@ +import { is } from 'bpmnlint-utils'; +import { defineRule, describeElement, ERROR_TYPES, reportErrors, type ModdleElement, type Reporter } from '../CamundaRules.js'; + +import { C7EventDefinitions } from './events/C7EventDefinitions.js'; +import { C7ExecutableRule } from './implementation/C7ExecutableRule.js'; +import { CamundaAttributes } from './implementation/CamundaAttributes.js'; +import { ImplementationHost } from './implementation/ImplementationHost.js'; +import { ReferenceBinding } from './implementation/ReferenceBinding.js'; +import { SetCommand } from './implementation/SetCommand.js'; +import { isExpression, isInteger } from './implementation/utils/values.js'; +import { C7Diagram } from './C7Diagram.js'; + +const VARIABLE_EVENTS = ['create', 'update', 'delete']; + +/** + * Reports a value Camunda 7 deploys but cannot use when the process runs: an empty external task + * topic or called element, an unknown binding, a user task priority that is not a number or + * expression, or a variable event that never triggers a condition. + */ +class C7BadValue extends C7ExecutableRule { + protected checkExecutable(node: ModdleElement, reporter: Reporter): void { + this.checkTopics(node, reporter); + if (is(node, 'bpmn:CallActivity')) { + this.checkCalledElement(node, reporter); + } + this.checkBindings(node, reporter); + if (is(node, 'bpmn:UserTask')) { + this.checkPriority(node, reporter); + } + if (is(node, 'bpmn:Event')) { + this.checkVariableEvents(node, reporter); + } + } + + private checkTopics(node: ModdleElement, reporter: Reporter): void { + for (const host of ImplementationHost.forElement(node)) { + if (!host.isExternal || host.attributes.defined('topic') !== '') { + continue; + } + reportErrors(node, reporter, { + message: `${host.label} is an external task with an empty ${host.prefix}:topic; no worker can fetch it`, + path: host.pathTo('topic'), + data: { + type: ERROR_TYPES.PROPERTY_VALUE_REQUIRED, + node: host.element, + parentNode: host.parentNode, + property: 'topic', + hint: `${host.command.of(`${host.prefix}:topic=`)}.`, + }, + }); + } + } + + private checkCalledElement(node: ModdleElement, reporter: Reporter): void { + if ( + node.get('calledElement') !== '' || + new CamundaAttributes(node).defined('caseRef') !== undefined + ) { + return; + } + reportErrors(node, reporter, { + message: `Call activity ${node.id} has an empty calledElement; it fails when it runs`, + path: ['calledElement'], + data: { + type: ERROR_TYPES.PROPERTY_VALUE_REQUIRED, + node, + parentNode: null, + property: 'calledElement', + hint: `${new SetCommand(node.id).of('calledElement=')}.`, + }, + }); + } + + private checkBindings(node: ModdleElement, reporter: Reporter): void { + const prefix = C7Diagram.of(node).prefix; + for (const binding of ReferenceBinding.forElement(node)) { + if (binding.problem !== 'value') { + continue; + } + reportErrors(node, reporter, { + message: `${prefix}:${binding.binding}="${binding.value}" on ${describeElement(node)} is not one of ${binding.allowed.join(', ')}; the engines accept the file but cannot resolve the reference as intended`, + path: [binding.binding], + data: { + type: ERROR_TYPES.PROPERTY_VALUE_NOT_ALLOWED, + node, + parentNode: null, + property: binding.binding, + hint: `${new SetCommand(node.id).of(`${prefix}:${binding.binding}=latest`)}.`, + }, + }); + } + } + + private checkPriority(node: ModdleElement, reporter: Reporter): void { + const priority = new CamundaAttributes(node).defined('priority'); + if (priority === undefined || isInteger(priority) || isExpression(priority)) { + return; + } + const prefix = C7Diagram.of(node).prefix; + reportErrors(node, reporter, { + message: `${prefix}:priority="${priority}" on ${describeElement(node)} is not a number or expression; creating the task fails`, + path: ['priority'], + data: { + type: ERROR_TYPES.PROPERTY_VALUE_NOT_ALLOWED, + node, + parentNode: null, + property: 'priority', + hint: `${new SetCommand(node.id).of(`${prefix}:priority=50`)}.`, + }, + }); + } + + private checkVariableEvents(node: ModdleElement, reporter: Reporter): void { + const prefix = C7Diagram.of(node).prefix; + const conditions = new C7EventDefinitions(node).indexedOfType('bpmn:ConditionalEventDefinition'); + for (const { definition, index } of conditions) { + const events = new CamundaAttributes(definition).defined('variableEvents'); + const unknown = (events ?? '') + .split(',') + .map((eventName) => eventName.trim()) + .filter((eventName) => eventName && !VARIABLE_EVENTS.includes(eventName)); + if (!unknown.length) { + continue; + } + reportErrors(node, reporter, { + message: `${prefix}:variableEvents="${events}" of ${describeElement(node)} lists ${unknown.join(', ')}; only ${VARIABLE_EVENTS.join(', ')} trigger the condition`, + path: ['eventDefinitions', index, 'variableEvents'], + data: { + type: ERROR_TYPES.PROPERTY_VALUE_NOT_ALLOWED, + node: definition, + parentNode: node, + property: 'variableEvents', + hint: `${SetCommand.forDefinition(node, index).of(`${prefix}:variableEvents=create, update`)}.`, + }, + }); + } + } +} + +export default defineRule( + { ruleName: 'c7-bad-value', kind: 'engine-specific', targetEngine: 'c7', impact: 'runtime' }, + C7BadValue, +); diff --git a/src/adapters/out/bpmnlint/engine/rules/c7/c7-binding-version.ts b/src/adapters/out/bpmnlint/engine/rules/c7/c7-binding-version.ts new file mode 100644 index 00000000..c27fff62 --- /dev/null +++ b/src/adapters/out/bpmnlint/engine/rules/c7/c7-binding-version.ts @@ -0,0 +1,68 @@ +import { is } from 'bpmnlint-utils'; +import { defineRule, describeElement, ERROR_TYPES, reportErrors, type ModdleElement, type Reporter } from '../CamundaRules.js'; + +import { C7ExecutableRule } from './implementation/C7ExecutableRule.js'; +import { CamundaAttributes } from './implementation/CamundaAttributes.js'; +import { ReferenceBinding } from './implementation/ReferenceBinding.js'; +import { C7Diagram } from './C7Diagram.js'; + +/** + * Reports a binding of `version` or `versionTag` without the version or tag it binds to, on a call + * activity, business rule task or form reference: Camunda 7 refuses the file. + */ +class C7BindingVersion extends C7ExecutableRule { + protected checkExecutable(node: ModdleElement, reporter: Reporter): void { + for (const binding of ReferenceBinding.forElement(node)) { + const problem = binding.problem; + if (problem === 'version') { + this.report(node, binding.binding, 'version', binding.version, '', reporter); + } else if (problem === 'versionTag') { + this.report(node, binding.binding, 'versionTag', binding.versionTag!, '', reporter); + } + } + if (is(node, 'bpmn:UserTask') || is(node, 'bpmn:StartEvent')) { + this.checkFormBinding(node, reporter); + } + } + + private checkFormBinding(node: ModdleElement, reporter: Reporter): void { + const attributes = new CamundaAttributes(node); + if ( + attributes.defined('formRef') !== undefined && + attributes.defined('formRefBinding') === 'version' && + attributes.defined('formRefVersion') === undefined + ) { + this.report(node, 'formRefBinding', 'version', 'formRefVersion', '', reporter); + } + } + + private report( + node: ModdleElement, + binding: string, + value: string, + missing: string, + placeholder: string, + reporter: Reporter, + ): void { + const prefix = C7Diagram.of(node).prefix; + const latest = `${prefix}:${binding}=latest`; + const alternative = binding === 'formRefBinding' ? latest : `\`${latest}\``; + reportErrors(node, reporter, { + message: `${describeElement(node)} has ${prefix}:${binding}="${value}" but no ${prefix}:${missing}; the engines refuse the file`, + path: [missing], + data: { + type: ERROR_TYPES.PROPERTY_DEPENDENT_REQUIRED, + node, + parentNode: null, + property: binding, + dependentRequiredProperty: missing, + hint: `\`bpmn set ${node.id} ${prefix}:${missing}=${placeholder}\` (or ${alternative}).`, + }, + }); + } +} + +export default defineRule( + { ruleName: 'c7-binding-version', kind: 'engine-specific', targetEngine: 'c7', impact: 'deploy' }, + C7BindingVersion, +); diff --git a/src/adapters/out/bpmnlint/engine/rules/c7/c7-boundary-host.ts b/src/adapters/out/bpmnlint/engine/rules/c7/c7-boundary-host.ts new file mode 100644 index 00000000..5b09c902 --- /dev/null +++ b/src/adapters/out/bpmnlint/engine/rules/c7/c7-boundary-host.ts @@ -0,0 +1,19 @@ +import { is } from 'bpmnlint-utils'; +import { C7Rule, defineRule, type ModdleElement, type Reporter } from '../CamundaRules.js'; +import { CompensationHandlerBoundary } from '../CompensationHandlerBoundary.js'; + +import { C7ExecutableScope } from './events/C7ExecutableScope.js'; + +/** Reports a boundary event attached to a compensation handler, which Camunda 7 refuses to deploy. */ +class C7BoundaryHost extends C7Rule { + protected checkElement(node: ModdleElement, reporter: Reporter): void { + if (is(node, 'bpmn:BoundaryEvent') && C7ExecutableScope.contains(node)) { + CompensationHandlerBoundary.check(node, reporter, 'the engines refuse'); + } + } +} + +export default defineRule( + { ruleName: 'c7-boundary-host', kind: 'engine-specific', targetEngine: 'c7', impact: 'deploy' }, + C7BoundaryHost, +); diff --git a/src/adapters/out/bpmnlint/engine/rules/c7/c7-called-element.ts b/src/adapters/out/bpmnlint/engine/rules/c7/c7-called-element.ts new file mode 100644 index 00000000..4e933588 --- /dev/null +++ b/src/adapters/out/bpmnlint/engine/rules/c7/c7-called-element.ts @@ -0,0 +1,48 @@ +import { is } from 'bpmnlint-utils'; +import { defineRule, ERROR_TYPES, reportErrors, type ModdleElement, type Reporter } from '../CamundaRules.js'; + +import { C7ExecutableRule } from './implementation/C7ExecutableRule.js'; +import { CamundaAttributes } from './implementation/CamundaAttributes.js'; +import { C7Diagram } from './C7Diagram.js'; + +/** Reports a call activity that calls nothing, or both a process and a case: Camunda 7 refuses the file. */ +class C7CalledElement extends C7ExecutableRule { + protected checkExecutable(node: ModdleElement, reporter: Reporter): void { + if (!is(node, 'bpmn:CallActivity')) { + return; + } + const calledElement: string | undefined = node.get('calledElement'); + const caseRef = new CamundaAttributes(node).defined('caseRef'); + const prefix = C7Diagram.of(node).prefix; + if (calledElement === undefined && caseRef === undefined) { + reportErrors(node, reporter, { + message: `Call activity ${node.id} has no calledElement (nor ${prefix}:caseRef); the engines refuse the file`, + path: ['calledElement'], + data: { + type: ERROR_TYPES.PROPERTY_REQUIRED, + node, + parentNode: null, + requiredProperty: 'calledElement', + hint: `\`bpmn set ${node.id} calledElement=\`.`, + }, + }); + } else if (calledElement !== undefined && caseRef !== undefined) { + reportErrors(node, reporter, { + message: `Call activity ${node.id} has both calledElement and ${prefix}:caseRef; the engines refuse the file`, + path: ['caseRef'], + data: { + type: ERROR_TYPES.PROPERTY_NOT_ALLOWED, + node, + parentNode: null, + property: 'caseRef', + hint: `Keep one: \`bpmn set ${node.id} ${prefix}:caseRef=\`.`, + }, + }); + } + } +} + +export default defineRule( + { ruleName: 'c7-called-element', kind: 'engine-specific', targetEngine: 'c7', impact: 'deploy' }, + C7CalledElement, +); diff --git a/src/adapters/out/bpmnlint/engine/rules/c7/c7-complex-gateway.ts b/src/adapters/out/bpmnlint/engine/rules/c7/c7-complex-gateway.ts new file mode 100644 index 00000000..fe0e34e5 --- /dev/null +++ b/src/adapters/out/bpmnlint/engine/rules/c7/c7-complex-gateway.ts @@ -0,0 +1,40 @@ +import { is } from 'bpmnlint-utils'; +import { defineRule, ERROR_TYPES, reportErrors, type ModdleElement, type Reporter } from '../CamundaRules.js'; + +import { C7FlowGraph } from './flows/C7FlowGraph.js'; +import { C7FlowRule } from './flows/C7FlowRule.js'; + +/** + * Reports a complex gateway a sequence flow connects: Camunda 7 does not run complex gateways and + * refuses the file. One that nothing connects deploys. + */ +class C7ComplexGateway extends C7FlowRule { + protected checkRunElement(node: ModdleElement, reporter: Reporter): void { + if (!is(node, 'bpmn:ComplexGateway')) { + return; + } + const graph = C7FlowGraph.forElement(node); + const incoming = graph.incoming(node); + const flows = [...incoming, ...graph.outgoing(node)]; + if (!flows.length) { + return; + } + const end = incoming.length ? 'destination' : 'source'; + reportErrors(node, reporter, { + message: `Complex gateway ${node.id} is connected; the engines do not run complex gateways and refuse the file (invalid ${end} '${node.id}' of sequence flow ${flows[0].id})`, + path: null, + data: { + type: ERROR_TYPES.ELEMENT_TYPE_NOT_ALLOWED, + node, + parentNode: null, + flows, + hint: `\`bpmn retype ${node.id} inclusiveGateway\` (or exclusiveGateway: one path).`, + }, + }); + } +} + +export default defineRule( + { ruleName: 'c7-complex-gateway', kind: 'engine-specific', targetEngine: 'c7', impact: 'deploy' }, + C7ComplexGateway, +); diff --git a/src/adapters/out/bpmnlint/engine/rules/c7/c7-dangling-reference.ts b/src/adapters/out/bpmnlint/engine/rules/c7/c7-dangling-reference.ts new file mode 100644 index 00000000..4507c527 --- /dev/null +++ b/src/adapters/out/bpmnlint/engine/rules/c7/c7-dangling-reference.ts @@ -0,0 +1,89 @@ +import { is } from 'bpmnlint-utils'; +import { defineRule, ERROR_TYPES, type ModdleElement, type Reporter } from '../CamundaRules.js'; + +import type { C7Host, HostExtension } from './extensions/C7Host.js'; +import { C7ExtensionRule } from './extensions/C7HostRule.js'; +import type { ExtensionHints } from './extensions/ExtensionHints.js'; +import { UnresolvedReferences } from './extensions/UnresolvedReferences.js'; + +/** An id reference of a camunda element that names no element of the type it needs. */ +interface DanglingReference { + /** `errorRef` */ + name: string; + /** the id the file wrote */ + id: string; + /** `bpmn:Error` */ + type: string; +} + +/** + * Reports an id reference inside a camunda extension element (the errorRef of + * camunda:errorEventDefinition) that points to no element or to one of another type; the Camunda + * 7 engines ignore the mapping when it runs. + */ +class C7DanglingReference extends C7ExtensionRule { + protected override readonly executableOnly = false; + + protected checkExtensions(host: C7Host, hints: ExtensionHints, reporter: Reporter): void { + for (const extension of host.camundaExtensions()) { + for (const reference of this.danglingReferences(extension.value)) { + this.report(host, hints, extension, reference, reporter); + } + } + } + + private danglingReferences(element: ModdleElement): DanglingReference[] { + const dangling: DanglingReference[] = []; + for (const property of element.$descriptor?.properties ?? []) { + if (!property.isReference || !property.isAttr || property.isMany) { + continue; + } + const name: string = property.ns?.localName ?? property.name; + const referenced = element.get(property.name); + const unresolved = UnresolvedReferences.of(element, name); + if (referenced && !referenced.$instanceOf(property.type)) { + dangling.push({ name, id: referenced.id, type: property.type }); + } else if (!referenced && unresolved !== undefined) { + dangling.push({ name, id: unresolved, type: property.type }); + } + } + return dangling; + } + + private report( + host: C7Host, + hints: ExtensionHints, + extension: HostExtension, + { name, id, type }: DanglingReference, + reporter: Reporter, + ): void { + const typeName = type.replace(/^bpmn:/, ''); + const placeholder = `<${typeName.charAt(0).toLowerCase()}${typeName.slice(1)}Id>`; + const candidates = this.idsOfType(host.node, type).slice(0, 5); + const pointAt = `Point it at an existing one${candidates.length ? ` (${candidates.join(', ')})` : ''}`; + host.report(reporter, { + message: `${hints.typeName(extension.local)} of ${host.describe()} references ${name}="${id}", which is not a ${type} in the file; the engines ignore the mapping when it runs`, + path: host.extensionPath(extension.index, name), + type: ERROR_TYPES.PROPERTY_VALUE_NOT_ALLOWED, + node: extension.value, + property: name, + hint: `${pointAt}. ${hints.rebuild(extension, { [name]: placeholder })}`, + }); + } + + /** The ids of the root elements of `type` (`bpmn:Error`) a reference may point at instead. */ + private idsOfType(node: ModdleElement, type: string): string[] { + let definitions = node; + while (definitions && !is(definitions, 'bpmn:Definitions')) { + definitions = definitions.$parent; + } + return (definitions?.rootElements ?? []) + .filter((root: ModdleElement) => is(root, type)) + .map((root: ModdleElement) => root.id ?? ''); + } +} + +export default defineRule( + { ruleName: 'c7-dangling-reference', kind: 'engine-specific', targetEngine: 'c7', impact: 'runtime' }, + C7DanglingReference, +); diff --git a/src/adapters/out/bpmnlint/engine/rules/c7/c7-definitions-extension.ts b/src/adapters/out/bpmnlint/engine/rules/c7/c7-definitions-extension.ts new file mode 100644 index 00000000..e363b881 --- /dev/null +++ b/src/adapters/out/bpmnlint/engine/rules/c7/c7-definitions-extension.ts @@ -0,0 +1,41 @@ +import { is } from 'bpmnlint-utils'; +import { C7Rule, defineRule, ERROR_TYPES, reportErrors, type ModdleElement, type Reporter } from '../CamundaRules.js'; + +/** + * Reports `bpmn:extensionElements` directly under `bpmn:definitions`: the BPMN schema does not + * allow them there, and Camunda 7 validates the file against it and refuses it. + */ +class C7DefinitionsExtension extends C7Rule { + protected checkElement(node: ModdleElement, reporter: Reporter): void { + if (!is(node, 'bpmn:Definitions')) { + return; + } + const values: ModdleElement[] = node.extensionElements?.values ?? []; + if (!values.length) { + return; + } + const types = values.map((value) => this.xmlName(value.$type)).join(', '); + reportErrors(node, reporter, { + message: `bpmn:definitions carries bpmn:extensionElements (${types}), which the BPMN schema does not allow there; the engines refuse the file`, + path: ['extensionElements'], + data: { + type: ERROR_TYPES.PROPERTY_NOT_ALLOWED, + node, + parentNode: null, + property: 'extensionElements', + hint: `Move the content to the process: \`bpmn ext remove ${node.id ?? ''} \`, then \`bpmn ext add ...\`.`, + }, + }); + } + + /** `camunda:properties` for the typed `camunda:Properties`, as the file spells it. */ + private xmlName(type: string): string { + const colon = type.indexOf(':'); + return `${type.slice(0, colon + 1)}${type.charAt(colon + 1).toLowerCase()}${type.slice(colon + 2)}`; + } +} + +export default defineRule( + { ruleName: 'c7-definitions-extension', kind: 'engine-specific', targetEngine: 'c7', impact: 'deploy' }, + C7DefinitionsExtension, +); diff --git a/src/adapters/out/bpmnlint/engine/rules/c7/c7-duplicate-extension.ts b/src/adapters/out/bpmnlint/engine/rules/c7/c7-duplicate-extension.ts new file mode 100644 index 00000000..00d41614 --- /dev/null +++ b/src/adapters/out/bpmnlint/engine/rules/c7/c7-duplicate-extension.ts @@ -0,0 +1,20 @@ +import { defineRule } from '../CamundaRules.js'; + +import { CamundaDuplicatesRule, type DuplicatedExtension } from './extensions/CamundaDuplicatesRule.js'; + +/** + * Reports a second camunda:inputOutput, connector, formData or failedJobRetryTimeCycle where the + * Camunda 7 engines read one; they refuse the file (ENGINE-01009). + */ +class C7DuplicateExtension extends CamundaDuplicatesRule { + protected readonly reportsRead = true; + + protected messageOf({ host, local, typeName, count }: DuplicatedExtension): string { + return `${host.describe()} has ${count} ${typeName} elements; the engines refuse the file (ENGINE-01009 multiple elements with tag name ${local})`; + } +} + +export default defineRule( + { ruleName: 'c7-duplicate-extension', kind: 'engine-specific', targetEngine: 'c7', impact: 'deploy' }, + C7DuplicateExtension, +); diff --git a/src/adapters/out/bpmnlint/engine/rules/c7/c7-empty-condition.ts b/src/adapters/out/bpmnlint/engine/rules/c7/c7-empty-condition.ts new file mode 100644 index 00000000..13193fc6 --- /dev/null +++ b/src/adapters/out/bpmnlint/engine/rules/c7/c7-empty-condition.ts @@ -0,0 +1,85 @@ +import { is, isAny } from 'bpmnlint-utils'; +import { defineRule, ERROR_TYPES, reportErrors, type ModdleElement, type Reporter } from '../CamundaRules.js'; + +import { C7EventDefinitions } from './events/C7EventDefinitions.js'; +import { C7FlowRule } from './flows/C7FlowRule.js'; +import { sh } from '../shell.js'; +import { C7Diagram } from './C7Diagram.js'; + +/** + * Reports a condition without expression and without `camunda:resource` script, on a sequence flow + * the engines evaluate or on a conditional event: Camunda 7 deploys it, and evaluating it fails. + */ +class C7EmptyCondition extends C7FlowRule { + protected checkRunElement(node: ModdleElement, reporter: Reporter): void { + if (is(node, 'bpmn:SequenceFlow')) { + this.checkFlow(node, reporter); + } else if (is(node, 'bpmn:Event')) { + this.checkEvent(node, reporter); + } + } + + private checkFlow(flow: ModdleElement, reporter: Reporter): void { + const condition = flow.conditionExpression; + if (!condition || !this.isEmpty(condition) || this.isIgnored(flow)) { + return; + } + reportErrors(flow, reporter, { + message: `Sequence flow ${flow.id} has an empty condition (no expression and no ${C7Diagram.of(flow).prefix}:resource): it deploys, but the engines fail when they evaluate it`, + path: ['conditionExpression'], + data: { + type: ERROR_TYPES.EXPRESSION_REQUIRED, + node: condition, + parentNode: flow, + property: 'conditionExpression', + hint: `Give it one: \`bpmn set ${flow.id} ${sh('condition=${expression}')}\`, or remove it: \`bpmn set ${flow.id} condition=\`.`, + }, + }); + } + + private checkEvent(event: ModdleElement, reporter: Reporter): void { + const definitions = new C7EventDefinitions(event); + const prefix = C7Diagram.of(event).prefix; + for (const definition of definitions.actedOn()) { + const condition = is(definition, 'bpmn:ConditionalEventDefinition') + ? definition.condition + : undefined; + if (!condition || !this.isEmpty(condition)) { + continue; + } + reportErrors(event, reporter, { + message: `Conditional event ${event.id} has an empty condition (no expression and no ${prefix}:resource): it deploys, but evaluating it fails when the process runs`, + path: ['eventDefinitions', definitions.all().indexOf(definition), 'condition'], + data: { + type: ERROR_TYPES.EXPRESSION_REQUIRED, + node: condition, + parentNode: event, + property: 'condition', + hint: `\`bpmn set ${event.id} ${sh('when=${expression}')}\` (or a script: \`bpmn set ${event.id} condition.${prefix}:resource=deployment://