Skip to content

refactor!: run the Camunda 7/8 engine rules as bpmnlint rules (experiment) - #123

Draft
emaarco wants to merge 30 commits into
mainfrom
emaarco/refactor-move-the-camunda-7-8-engine-rules-out-o
Draft

emaarco wants to merge 30 commits into
mainfrom
emaarco/refactor-move-the-camunda-7-8-engine-rules-out-o

Conversation

@emaarco

@emaarco emaarco commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

Experiment, most likely not merged. It shows what moving the Camunda 7 / 8 engine rules onto bpmnlint costs: about 2 times the code and about 2 times the lint time of today's profiles.

Why

#114 asked whether bpmn-cli's own engine profiles (src/domain/validation/profiles/, ~4,250 lines) could shrink by leaving the checks camunda-compat already covers to camunda-compat and running the rest as bpmnlint rules on the framework of @miragon/bpmnlint-plugin-rules. This branch tries it end to end, so the trade-off is measured instead of guessed.

What

  • Only bpmnlint rules: the profiles are gone from the domain. bpmn-cli's engine checks are 120 single-check bpmnlint rules (70 for Camunda 7, 50 for Camunda 8) in src/adapters/out/bpmnlint/engine/rules/c7|c8/, one rule per file. They run together with camunda-compat's rules for the file's engine version in the plugin's single-pass lint(root, rules) (feat: per-version camunda-compat rules and a single-pass lint for a host's engine rules bpmnlint-rules#113). The adapter has no checks outside that call. The W_C7_* / W_C8_* codes and hints stay.
  • Layout: engine/run/ turns reports into findings (EngineRun, EngineFindings, EngineCodes, EngineHint, CompatVerdicts). engine/rules/ holds the base class CamundaRule, the rules (*Rule) and their helpers in support/ folders. Every file is named after its class, and each rule's header says what it checks, why, and gives an example.
  • One check per rule: each rule is named <engine>-<subject>-<requirement> (e.g. c7-message-event-needs-message, class C7MessageEventNeedsMessageRule) and declares one code and impact via defineRule({ ruleName, code, impact }, Class). Several rules may share a code. Shared logic lives in abstract bases only where at least two rules use it (C7ExecutableRule, C7EventReferenceRule, C7ListenerRule, C8FeelRule, C8HostRule, C8PublishMessageRule, VendorElementsRule, …). The naming scheme is in CONTRIBUTING.
  • Fewer findings: findings a file written by bpmn-cli cannot have are dropped, because the CLI refuses or normalises them: several event definitions, extension elements on bpmn:definitions, duplicate Camunda 7 extensions, empty conditions and values, and the schema text checks (child order, NCName ids, xsd booleans). c7- / c8-boundary-host are dropped because the structural E_INVALID_HOST always reports the same element.
  • Checks on the file as written: unknown vendor elements, unprefixed attributes, non-boolean camunda booleans and zeebe:publishMessage are rules too. The typed parse drops or rewrites what they look at, so they read the file as written through WrittenModel, which EngineRun records per run.
  • Fix: camunda-compat's start-event rule is keyed by bpmnlint/start-event-required, the name its reports carry, so a missing start event gets W_C8_DEPLOY_START_EVENT again.
  • Why it does not get as small as the profiles: camunda-compat has one Camunda 7 rule (history-time-to-live), so the Camunda 7 checks had to be ported; for Camunda 8 it fully covers about 11 codes. The glue that maps camunda-compat's reports and the bridge to the file as written are new.
  • Why it is slower: the rules need the typed moddle tree, so the document is written to XML and parsed again for every run.

Verification

  • The old profile tests, restored from main, are the parity check; cases of the dropped findings are removed and listed in docs/camunda.md. bpmn validate --json for c7 and c8 over all 164 .bpmn files of the repository changed only by the dropped findings and the start-event fix, and stayed byte-identical through the helper cleanup, the restructuring and the split into single-check rules.
  • npm run gate passes locally (tsc, biome, knip, jscpd, vitest 3,403 tests, tools/iso/check.mjs, bench regress, fuzz). The plugin is installed from a locally packed tarball, so CI cannot install it.
  • Lines (wc -l): engine adapter 8,755 (run 839) against 4,249 for the profiles on main; 5,701 with the 17 grouped rules before the split. Rule specs: 12,104 (109 spec files). The on-demand bundle budgets grew: C7 126 KB / 36 KB gzip (was 119 / 34), C8 369 KB / 103 KB gzip (was 365 / 102).
  • Engine lint time, warm, median, A/B in one process against main, measured before the last cleanup (not re-measured):
Model main this branch
C7, ~500 flow nodes 50 ms 105 ms
C8, ~500 flow nodes 90 ms 230 ms
C8, ~1,800 flow nodes 340–550 ms 870–950 ms
  • A rule census over the scenario models, 80 fuzz walks and the bench edits: 3 of 40 Camunda 7 and 11 of 87 Camunda 8 rules ever fired there; the others guard against mistakes in hand-written files or older engine versions. bpmn set and bpmn ext add can write any vendor content, so nearly every rule can fire after an edit, and running only some rules after an edit is not safe.

Breaking changes

  • loadProfile, loadProfiles, runProfile and ProfileReport are no longer exported.
  • Codes no longer reported: W_C7_DEPLOY_BOUNDARY_HOST, W_C8_DEPLOY_BOUNDARY_HOST, W_C7_MULTIPLE_EVENT_DEFINITIONS; W_C7_DEPLOY_SCHEMA / W_C8_DEPLOY_SCHEMA now only for unprefixed attributes, no longer for child order, ids or booleans in the file text.

Refs #114

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.
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.
…per run

EngineRun lints with the plugin's EngineLint.lint, which visits every element
once for all rules instead of once per rule; a rule that throws comes back as
its rule-error, so the per-rule guard goes. DocumentNamespaces walks the whole
document: it is read once per run and handed to the checks instead of eight
times. Engine time on a 1,800-node model drops from about 1.0 s to 0.55 s (C8)
and 0.65 s to 0.45 s (C7).

The Camunda 7 / 8 chunk budgets grow by about 5 KB / 1 KB gzip: bpmnlint's
linter, which resolves the configs, is now imported by the rules chunk.
…int(root, rules)

The plugin no longer resolves configs or takes the host's rules: bpmn-cli
builds the rule list itself, camunda-compat's rules for the engine version
plus its own, each own rule at the category of its impact, and runs it in
one walk. The severity overrides become categories or a left-out rule.
…one report helper

The 39 Camunda 7 rules become six group rules (c7-events, c7-flows,
c7-implementation, c7-values, c7-extension-content, c7-vendor-content),
each with one dispatch per element. Every report carries the former rule
name as data.finding; a rule declares its findings with code and impact,
so codes and severities come from the declaration and EngineCodes keeps
only the camunda-compat table. CamundaRule.report builds the
camunda-compat report shape once (node, parentNode, finding).

Shared helpers: EngineScope (where the engine runs content), SetCommand
(the bpmn set / ext address), Values, FirstOfKey (first of a key). The
C7 helpers collapse into C7Host (with the extension hints and element
tree), C7Diagram (with the unresolved references), C7EventDefinitions,
ImplementationHost and CamundaPlacement. The C8 rules only declare their
single finding; EngineRun and CompatVerdicts select by finding.

The rule specs are regrouped per group rule and assert per finding.
Findings are identical: same tests pass and fail as before, and
`bpmn validate --platform c7|c8 --json` gives byte-identical output on all
176 .bpmn files of the repository.

Lines (wc -l):
- engine adapter: 12,357 -> 9,047
- rules/c7: 5,537 -> 2,149
- rules/ shared files: 287 -> 416
- c7 rule specs: 4,736 -> 4,696
…hare the vendor-content checks

The 22 Camunda 8 rules become six group rules (c8-events, c8-subscriptions,
c8-extensions, c8-vendor-content, c8-feel-expression, c8-schema) on
CamundaRule and its report helper; every report keeps its former rule
name as data.finding, so codes, severities and the parity tests stay.

Shared with Camunda 7: VendorHost (where a host's findings land, its
path, its bpmn set/ext address and its vendor element tree, now the base
of C7Host and C8Host), VendorContentRule (the foreign-content, unknown
and misplaced attribute and extension findings, worded by an engine
phrase table, the fixes per engine), ModdlePath, SetCommand.at,
EngineScope.C8 and Values. The C8 helpers collapse into C8Host,
C8EventDefinitions (checked definition, message holders, used messages)
and ZeebeHint (ZeebeFixHint merged in). ZeebeSchema is gone and
CamundaDescriptor is a thin adapter: the rules read kit.ZEEBE and
kit.CAMUNDA. The rule specs parse with the plugin's moddle extensions.

c8-schema drops its unprefixed-attribute branch: UnprefixedAttributes
reports every such attribute, and EngineRun's filter only caught the
copy on the element itself, so an attribute of an event definition,
loop or condition was listed twice. That filter is gone too.

`bpmn validate --platform c7|c8 --json` gives byte-identical output on
all 176 .bpmn files of the repository; the same 8 start-event tests
fail as before (plugin #113 names bpmnlint/start-event-required).

Lines (wc -l):
- engine adapter: 9,047 -> 6,449
- rules/c8: 4,832 -> 2,302
- rules/c7: 2,149 -> 1,855
- rules/ shared files: 416 -> 644
- c8 rule specs: 3,334 -> 3,278
… shrink the engine glue

EngineRun now only orchestrates: parse, lint, verdicts, findings.
CompatVerdicts holds every place bpmn-cli reads camunda-compat
differently: the severity overrides (rules()), the processes the
engines do not run, the send tasks a zeebe:publishMessage implements
and the Camunda 8 verdicts (apply()).

The loop-input filter was no camunda-compat correction but one of
bpmn-cli's own c7-multi-instance: the rule now reads a loopDataInputRef
the parse dropped from C7Diagram's unresolved references, which the run
records from the same import warnings.

Glue clones removed:
- CompatHints merged into EngineHint (one xmlName), which quotes with sh.
- CamundaBooleans, PublishMessages, UnknownVendorElements and
  UnprefixedAttributes extend OwnModelCheck (kit, doc, namespaces,
  engine, the executable elements) and address and name elements with
  SetCommand and describeElement instead of private copies;
  UnknownVendorElements guesses with didYouMean (Levenshtein, as main's
  profiles did) and asks the rules' typed parse whether it knows a zeebe
  type instead of reading ZEEBE_DESCRIPTOR, which leaves the engine kit.
- The NCName regex lives once, in Values.isNcName.

`bpmn validate --platform c7|c8 --json` gives byte-identical output on
all 176 .bpmn files of the repository; the same 8 start-event tests
fail as before (plugin #113 names bpmnlint/start-event-required).

Lines (wc -l):
- engine adapter: 6,449 -> 6,323
- glue (engine/*.ts): 1,648 -> 1,514
…le chunk budgets

docs/camunda.md lists the group rules per code (bpmn-cli/c7-events,
bpmn-cli/c8-subscriptions, ...) and says how the checks run now: one
walk with the plugin's lint, camunda-compat's compatRules, six own rules
per engine that declare their checks. CONTRIBUTING.md names
EngineFindings, CompatVerdicts and OwnModelCheck instead of CompatHints.
docs/library.md gets the measured bundle sizes.

tools/iso/budget.json, rounded up to the next whole KB:
- Camunda 7 rules: 134 / 37 KB -> 117 / 34 KB (116.4 / 33.2 KB)
- Camunda 8 rules: 374 / 104 KB -> 361 / 101 KB (360.2 / 100.7 KB)
- engine run: 50 / 15 KB -> 49 / 17 KB (48.6 / 16.1 KB)

The engine run has been over its 15 KB gzip budget since bd576eb, which
moved bpmnlint's rule running into the plugin's lint() and reads the
namespaces once per run (15.8 KB gzip at f6e5df0). The glue now shares
describeElement, didYouMean, SetCommand, sh and Values with the rule
chunks instead of private copies, which counts them in the engine chunk
too: minified it shrinks (49.2 -> 48.6 KB), gzip grows by 0.25 KB. The
whole entry and the applyToXml-only entry stay within their budgets
(1073.0 / 337.0 and 776.1 / 242.6 KB).
The rule files carried their bpmnlint rule id (c7-events.ts) next to helper
files named after their class (C7EventDefinitions.ts). Every file is now named
after its class in PascalCase: C7Events.ts, Camunda7Rules.ts (was index.ts),
and the specs follow (C7Events.test.ts). The rule ids stay in the
declarations. describe.ts, shell.ts and didYouMean.ts held free functions:
they are the classes ElementKinds (describe, trigger), Shell (quote, slotted)
and DidYouMean (of).
Each rule header now says what it checks, what the engine does with such a file, one bullet per reported finding in findings-map order, and an XML example where the mistake is not visible in the diagram. C7Implementation now declares the c7-bad-value finding it reports (empty external topic) in its findings map.
The own engine rules reported problems that bpmn-cli's own writes rule out,
or that bpmn-cli has no context for:

- c7-boundary-host, c8-boundary-host: always repeated by the structural
  E_INVALID_HOST; StructuralValidator.withProfile no longer filters them
- c7-multiple-event-definitions: the CLI keeps one definition per event
- c7-definitions-extension, c8-schema (the whole rule): ext add refuses
  bpmn:definitions, and the CLI writes NCName ids
- c7-duplicate-extension, c7-async-duplicate-extension: ext add merges or
  refuses a second single-instance element
- c7-empty-condition and the empty topic / calledElement of c7-bad-value:
  set removes empty values and refuses empty conditions
- the schema checks of the file's text (SchemaText, SchemaTextFindings,
  BpmnSchema): child order, IDREFs, xsd:booleans, unreadable ids
- c8-event-definition: several definitions, a definition without id;
  c8-event-gateway: fewer than two listed as outgoing; c8-extension: a
  header key twice in a second header list

c8-duplicate-extension stays: without it, camunda-compat's reports on the
doubled element come back as new findings.

The iso budgets drop to the new sizes, rounded up to the next whole KB.

BREAKING CHANGE: validate no longer reports W_C7_DEPLOY_BOUNDARY_HOST,
W_C8_DEPLOY_BOUNDARY_HOST, W_C7_MULTIPLE_EVENT_DEFINITIONS,
W_C7_DEPLOY_DEFINITIONS_EXTENSION, W_C7_DEPLOY_DUPLICATE_EXTENSION,
W_C7_DUPLICATE_EXTENSION and W_C7_EMPTY_CONDITION, and reports
W_C8_DEPLOY_SCHEMA only for unprefixed attributes.
…upport helpers

Move EngineRun, EngineFindings, EngineCodes, EngineHint and CompatVerdicts into engine/run/,
give every rule the suffix Rule (C7EventsRule, C8FeelExpressionRule, ...), and move the helpers
of each engine into rules/c7/support/ and rules/c8/support/, the shared ones into
rules/support/. CamundaRules.ts becomes CamundaRule.ts. Rule ids and finding names stay.
…n the file as written

UnprefixedAttributes, CamundaBooleans, PublishMessages and UnknownVendorElements become bpmnlint
rules run in the same lint call (c7/c8-unprefixed-attributes, c7-camunda-booleans,
c8-publish-message, c7/c8-unknown-vendor-elements); OwnModelCheck goes away. The typed parse drops
or rewrites what they check and its warnings name no element, so the engine run records the file
as written (WrittenModel): a rule reads the written twin of each element it visits and reports on
the typed one. Their findings keep the old order and do not drop camunda-compat subjects.

The rule chunks grow by what moves out of the engine run: budgets c7 109/32 -> 119/35 KB,
c8 355/99 -> 366/102 KB, engine 38/14 -> 27/11 KB.
@emaarco
emaarco force-pushed the emaarco/refactor-move-the-camunda-7-8-engine-rules-out-o branch from 9fd4e0c to 59e373f Compare October 29, 2026 07:09
Each of the nine group rules (C7EventsRule, C7FlowsRule, ...) reported several findings, each
bundling several checks. Now one file holds one rule with one check, named after what must hold
(c7-message-needs-name, C7MessageNeedsNameRule): seventy rules, with small abstract bases where two
or more share a walk (C7ExecutableRule, C7EventReferenceRule, C7ListenerRule, ...). The vendor
element base splits into the unknown element rule and the C7/C8 placement rules.

Codes, messages, hints, paths and the order of the findings stay as they were; one spec per rule,
split from the old specs.
The six group rules (C8EventsRule, C8SubscriptionsRule, ...) become fifty rules with one check
each, named after what must hold (c8-error-code-unique, C8ErrorCodeUniqueRule), sharing walks in
small abstract bases (C8FeelRule, C8HostRule, C8SubscriptionUniqueRule, C8PublishMessageRule, ...).
CompatVerdicts reads the publishMessage and duplicate extension rules by their new names.

Codes, messages, hints, paths and the order of the findings stay as they were; one spec per rule,
split from the old specs, and a spec for c8-timer-value-valid.
With one check per rule the findings map and data.finding go: defineRule takes the rule's code and
impact and derives kind and engine from the name, and EngineFindings maps a report by its rule.
docs/camunda.md and CONTRIBUTING list the new rules and their naming scheme.

The rule chunks grow with the per-rule modules: the budgets rise by 8 KB / 1 KB (C7) and
4 KB / 1 KB (C8), minified / gzip.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant