Validators
A library host can run its own checks inside a write. They see exactly the XML that would be written, and an error a change adds refuses the write, like a structural error does. The CLI takes no validators of its own: it runs only the project's bpmnlint, as an advisory one (see Advisory validators). Passing validators is library use.
Passing validators
The validators option takes a list. These calls accept it:
- the in-memory API:
applyToXml,newXml,layoutXmlandvalidateXml; - the pipeline on a
Doc:mutateDoc,layoutDoc(MutationOptions) andcheckDoc(CheckOptions); - the file helpers of
@miragon/bpmn-cli/node:mutateFile,mutateDocToFile,layoutFile,layoutDocToFile,checkFileandcheckLoadedDoc.
A validator is a function (xml, ctx) => findings, sync or async. It is named after the function, or validator<n> (1-based) when the function has no name. A NamedValidator object gives more:
| field | meaning |
|---|---|
name | shown with every finding: [name] in text, validator in JSON |
validate | (xml, ctx) => findings: reads the XML |
validateDoc | (doc, ctx) => findings: reads the parsed Doc instead (see below); read it, never change it |
detail | one line on why it runs, shown in results |
covers | finding code -> the lint warning it says again (see Covers) |
blocks | false: advisory, its errors never block a write (see Advisory validators); default true |
An object needs validate or validateDoc. Without either the call fails with E_VALIDATOR_FAILED.
validateDoc gets the live document before the ops and in a check. After the ops it gets a fresh parse of the candidate, so a write parses once more than with validate. It saves a serialisation only for a document created in memory (newXml, Doc.create): that one has no text to hand to validate.
The context ctx has:
| field | meaning |
|---|---|
file | the file being written or checked, when there is one (see The file) |
phase | 'before', 'after' (a write) or 'check' (a validation) |
platform | the engine platform of the document: 'c7', 'c8' or 'none' (detected, or the explicit platform option) |
ops | the ops of the transaction (empty for a check) |
doc() | the XML parsed into a Doc (cached per phase; read it, never change it) |
Findings
A validator returns an array of findings (ValidatorFinding):
| field | meaning |
|---|---|
severity | error in any case is an error; anything else, such as warning or WARN, is a warning |
code | a stable rule id, such as UNNAMED_TASK |
ruleId | read as the code when there is no code (naming/task); without either the code is FINDING |
message | the text |
element | the id of the element the finding is about (optional) |
related | other ids it involves (optional) |
hint | how to fix it (optional) |
key | the finding's identity before and after a change (optional, see Before and after) |
leftOut | a short reason why a write does not compare this finding (optional, see Left out) |
So a finding of the shape { severity: 'ERROR' | 'WARN', ruleId, message } is accepted as it is.
import { applyToXml, CliError, newXml, type NamedValidator } from '@miragon/bpmn-cli';
// a house rule: a path ends in an end event, never in a task or a gateway
const houseRules: NamedValidator = {
name: 'house-rules',
detail: 'our modelling guideline',
covers: { DEAD_END: 'W_DEAD_END' },
validateDoc: (doc) =>
doc.processes()
.flatMap((process) => doc.flowNodes(process))
.filter((node) => node.$type !== 'bpmn:EndEvent' && doc.outgoing(node).length === 0)
.map((node) => ({ severity: 'error', code: 'DEAD_END', message: `${node.id} has no outgoing sequence flow`, element: node.id })),
};
let { xml } = await newXml({ processName: 'Order handling' });
({ xml } = await applyToXml(xml, [
{ op: 'add', kind: 'start', name: 'Order received' },
{ op: 'add', kind: 'end', name: 'Done', after: 'Event_OrderReceived' },
]));
try {
await applyToXml(xml, [{ op: 'add', kind: 'userTask', name: 'Archive', in: 'Process_OrderHandling' }], { validators: [houseRules] });
} catch (err) {
if (!(err instanceof CliError)) throw err;
err.code; // 'E_VALIDATION'
err.message; // 'The change would introduce 1 error(s) reported by validator house-rules; nothing was written'
err.details.errors; // [{ code: 'DEAD_END', message: 'Activity_Archive has no outgoing sequence flow', validator: 'house-rules', severity: 'error', element: 'Activity_Archive' }]
}
const edit = await applyToXml(xml, [{ op: 'add', kind: 'userTask', name: 'Check invoice', after: 'Event_OrderReceived' }], { validators: [houseRules] });
edit.result.validation.validators; // [{ name: 'house-rules', detail: 'our modelling guideline', errors: [], warnings: [], preexisting: [], resolved: [], counts: { errors: 0, warnings: 0 } }]Before and after
A write runs every validator twice:
- before: on the document before the ops (its text as read);
- after: on the candidate after the ops, the layout, the format ops and the text-preserving step, so on exactly what would be written.
Then the findings are compared:
- An error the change added refuses the write with
E_VALIDATION. Itsdetails.errorslist the new errors, each withvalidatorandseverity. Nothing is written, so a check of the document does not show them: they are only in the refusal.force: truewrites anyway, and the errors stay inresult.validation.errors(in text:forced [house-rules] DEAD_END Activity_Archive: ...). - An error the document already had does not block. It becomes a
W_PREEXISTING_ERRORwarning whose message starts with its code (DEAD_END: Activity_Archive has no outgoing sequence flow (already in the model before this change)). The result counts it with the other warnings the file already had (result.warnings.preexistingCount), so an old problem never makes a file uneditable. - A warning the change added is reported (
result.warnings.added). A warning the document had is not reported again. It only shows in the validator'scounts.warnings, not inresult.warnings.preexistingCount. - A finding the change removed is resolved (
result.warnings.resolved).
A finding before and one after are the same when they have the same validator, code and severity and are about the same element. An element the ops renamed keeps its findings. A finding without an element is matched by its code and message, with the old ids in the message replaced by the new ones. A key replaces that identity, for a message that changes with every write (process Process_1 has 11 tasks). Each finding before matches one finding after at most, so two equal findings count as two. Two validators of one name are compared in order: the second run after with the second run before.
The structural check comes first: a change that adds a structural error is refused before the validators see the candidate.
A check (validateXml, checkDoc, checkFile, checkLoadedDoc) runs each validator once, with phase check, on the document's own diagram (before the layout dry run). It reports every finding. An error makes ok false in the validateXml report (validationReport(check)). checkDoc, checkFile and checkLoadedDoc have no ok: they list the error in validation.errors.
A validator that throws, or returns no array, fails the call with E_VALIDATOR_FAILED (details.validator names it). Nothing is written. For a validator that throws, the message says which run failed: Validator broken failed on the document before the change: boom; nothing was written (or on the changed document, on the file). One that returns no array gets Validator broken returned undefined instead of an array of findings; nothing was written.
Left out
A validator may know that some of its findings say nothing about the change: they come from data the file does not keep, so a write would report them as new or resolved at random. It marks such a finding with leftOut, a short reason (the project's bpmnlint: incoming / outgoing lists the file does not keep, W_LINT_FAILED, no rule ran). In a write such a finding, before or after, is never added, resolved, kept or pre-existing, never refuses the write and covers no lint warning. A finding the document had that is still in the result, now marked, is not resolved either. It is counted: in counts, so the totals equal a check's on the result, and in the report's leftOut, one { reason, count } per reason in the order of the findings (absent when there is none). renderMutation ends the validator line with 1 left out (dead ends the host checks elsewhere; a check lists it), after the counts of an advisory validator (the project's bpmnlint: 7 left out (incoming / outgoing lists the file does not keep; `bpmn validate <file>` lists them)), and mutationSummary lists them in leftOut ([{ validator, reason, count }], text: validator gate: 1 left out (...)). A check reports a finding with leftOut like any other, and so does a write of a created document (newXml, bpmn new) for an advisory validator, which has no run before to compare with. The field itself is never in a result.
Covers
A validator may say again what a lint warning says, as DEAD_END above repeats W_DEAD_END. covers: { DEAD_END: 'W_DEAD_END' } drops the lint warning about the same element, so each problem is reported once. Only a finding with an element covers a lint warning. A finding without a hint of its own takes the dropped warning's hint. When a change fixes both, the resolved list names the problem once too, as the validator's finding.
Advisory validators
A validator with blocks: false reports and never refuses:
- An error it finds in a change is among the added warnings (
result.warnings.added) withseverity: 'error', not amongresult.validation.errors. The write goes through withoutforce. In text it readswarning [<name> error] <code> <element>: .... - An error the document already had is no
W_PREEXISTING_ERROR: like an old warning, it only shows in the validator'scounts. - A check reports its errors as errors, so
okis false in thevalidateXmlreport. - A document created in memory (
newXml) has no file before it: an advisory validator reports everything it finds in the new model. - Its
ValidatorReportsaysblocks: false.renderMutationadds to its validator line how many findings the change added and resolved and how many the file already had (validator bpmnlint (.bpmnlintrc in models): 1 error(s), 10 warning(s) in the result (3 added, 1 resolved, 8 already in the file)), and folds three or more of its findings with one code and one message into one line.mutationSummarycounts the findings the file already had inwarnings.preexistingCount. The summary does not keep the validators, so pass their names torenderSummary(summary, { advisory: advisoryValidators(result) })for the same folding.
The project's bpmnlint is such a validator (bpmnlint). A host can run it the same way, advisory or blocking: the engine's default configuration from the plugin bpmn-cli bundles, or the repository's own .bpmnlintrc through the file helpers (library.md).
import { applyToXml, bpmnlintValidator, idConventionsFromLint, ID_NAMING_RULE, resolveBpmnlintConfig } from '@miragon/bpmn-cli';
import { bpmnlintEngine, mutateFile } from '@miragon/bpmn-cli/node';
const engine = bpmnlintEngine();
const rc = engine.plugin.getDefaultLintConfig({ engine: 'c7' });
const resolved = await resolveBpmnlintConfig(engine, rc);
// the engine defaults turn element-id-naming on: new ids follow it
const { conventions, notes } = idConventionsFromLint(resolved.rules[ID_NAMING_RULE], engine.plugin.naming);
const lint = bpmnlintValidator(engine, resolved, { detail: 'default rules', notes });
await applyToXml(xml, ops, { validators: [lint], conventions });
// the repository's .bpmnlintrc (bundled rules only), its errors blocking
await mutateFile('processes/order.bpmn', ops, { lintFrom: repoRoot, lintBlocks: true });The engine defaults (getDefaultLintConfig({ engine: 'c7' }) and 'c8') and preset: 'automation' turn @miragon/rules/element-id-naming on, so the lint checks the ids bpmn-cli generates. idConventionsFromLint returns { conventions, notes }. Pass conventions to the write and notes to bpmnlintValidator; without them the lint reports every new id (library.md). The file helpers do this for the repository's .bpmnlintrc.
The file
ctx.file is the file being written or checked:
applyToXml,newXml,layoutXmlandvalidateXml: thefileoption. A string in memory has no file otherwise.mutateDoc,layoutDocandcheckDoc: thefileoption, elsedoc.file.- The file helpers: the
fileoption, else the file they write (theouttarget without) or check.
Results
- A write reports one
ValidatorReportper validator inresult.validation.validators:name,detail,blocks(onlyfalse, for an advisory one),errors(the errors the change added; only in a forced write),warnings(the warnings it added),preexisting,resolved,counts(the errors and warnings after the change) andleftOut(only when it left findings out, see Left out). The added warnings are also inresult.warnings.added, and the errors of a forced write inresult.validation.errors, each withvalidator.renderMutationprints one line per validator:validator house-rules (our modelling guideline): 0 error(s), 0 warning(s) in the result. - A check reports every finding:
validatorsof thevalidateXmlreport (validation.validatorsofcheckDoc,checkFileandcheckLoadedDoc), withpreexistingandresolvedempty. The findings are also among the report'serrorsandwarnings.renderValidationprints them as[house-rules] DEAD_END Activity_Archive: ...and addsvalidator house-rules (our modelling guideline): 1 error(s), 0 warning(s) in the filebefore the layout line and the totals.