@formicoidea/labre-framework-bpmn 0.40.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/import.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { InterchangeImportError, } from '@formicoidea/labre-core/blocks/surface';
1
2
  import { ConnectorMode, PointStyle, StrokeStyle } from '@formicoidea/labre-core/model';
2
3
  import { Bound } from '@formicoidea/labre-core/global/gfx';
3
4
  import { ASSOCIATION_STROKE, ASSOCIATION_WIDTH, MESSAGE_STROKE, MESSAGE_WIDTH, NODE_SIZE, POOL_BAND_WIDTH, POOL_REF_HEIGHT, POOL_REF_WIDTH, SEQUENCE_STROKE, SEQUENCE_WIDTH, } from './consts.js';
@@ -5,21 +6,33 @@ import { BPMN_FORMAT_ID, BPMN_NS, BPMN_OWN_DECLARATIONS, BPMN_SCOPE, BPMN_XML_OF
5
6
  import { bpmnNodeProps } from './presets.js';
6
7
  import { BPMN_ROLE } from './roles.js';
7
8
  /**
8
- * The remarks whose wording is FIXED, as `[key, English]` pairs.
9
+ * The remarks whose wording is FIXED, as `[key, English]` pairs — one entry
10
+ * per SHAPE of sentence, never one per exact string: a remark that names
11
+ * something out of the file (`<boundaryEvent>`, an id, a count of lanes)
12
+ * carries `{{name}}` / `{{count}}` holes in the fallback here, filled at the
13
+ * call site by `messageParams` (`InterchangeNote.messageParams`) — never by
14
+ * minting a new key for every value the file happens to carry.
9
15
  *
10
16
  * A reader is a pure function of text (`docs/adr/0012`, P3) and has no `std`,
11
- * so it cannot ask the host's catalogue for anything: it declares the key on
12
- * the note and `reportInterchangeImport` resolves it when it draws the report
13
- * (`InterchangeNote.messageKey`). The English string stays here and stays the
14
- * fallback, so the console table and a playground with no catalogue read
15
- * exactly what they read before.
17
+ * so it cannot ask the host's catalogue for anything: it declares the key
18
+ * (and, where the sentence has a hole, the params) on the note, and
19
+ * `reportInterchangeImport` resolves both when it draws the report
20
+ * (`InterchangeNote.messageKey` / `.messageParams`). The English string stays
21
+ * here and stays the fallback, so the console table and a playground with no
22
+ * catalogue read exactly what they read before: `translateKey` fills a
23
+ * `{{…}}` hole from `messageParams` even with no `TranslationProvider`
24
+ * registered (`fillPlaceholders`), so the ENGLISH sentence a call site builds
25
+ * is unchanged by giving it a key.
16
26
  *
17
- * These three and no others, and the line is not arbitrary: every remaining
18
- * remark NAMES something out of the file — `<boundaryEvent>`, an id, a count of
19
- * lanes — and the seam has neither interpolation nor pluralisation. A key for
20
- * one of those would be a sentence with holes in it that a translator cannot
21
- * see the shape of, which is the same refusal the interchange count labels
22
- * already make (`interchange-import.ts`).
27
+ * Plural wording is the host's: where a count can be 0, 1 or many, the
28
+ * fallback stays grammatically neutral (`'{{count}} lane(s)…'`) rather than
29
+ * picking English's own singular/plural — the same rule
30
+ * `interchange-import.ts`'s own count labels follow.
31
+ *
32
+ * `BPMN_QUARANTINE_REASON` (declared further down, with its own key pairing)
33
+ * is the one exception: its plain string is ALSO written verbatim into
34
+ * `ForeignInterchange.quarantined[].reason` (data, never translated), so it
35
+ * cannot become a `[key, english]` pair without breaking that contract.
23
36
  *
24
37
  * They are contributed to the manifest by `./translations.ts`, with the
25
38
  * framework, because they ship in the BPMN bundle rather than in core.
@@ -37,6 +50,80 @@ export const BPMN_IMPORT_REMARKS = {
37
50
  'com.labre.bpmn.import.remark.must-understand',
38
51
  'The file declares an extension that it says MUST be understood to read the model correctly. Labre does not understand it: the import went ahead, and this reading of the process may be wrong.',
39
52
  ],
53
+ duplicateId: [
54
+ 'com.labre.bpmn.import.remark.duplicate-id',
55
+ 'Two elements in this file share the id "{{id}}", which BPMN requires to be unique across a document. Both were imported; the second will be written back under an id Labre mints.',
56
+ ],
57
+ carriedChild: [
58
+ 'com.labre.bpmn.import.remark.carried-child',
59
+ '<{{tag}}> has no Labre artefact, so it is kept verbatim on the nearest element that has one. It is not drawn, and no validation rule sees it.',
60
+ ],
61
+ inventedPoolLayout: [
62
+ 'com.labre.bpmn.import.remark.invented-pool-layout',
63
+ 'The participant "{{name}}" arrived with no diagram, so Labre placed its pool beside the drawing.',
64
+ ],
65
+ inventedLaneLayout: [
66
+ 'com.labre.bpmn.import.remark.invented-lane-layout',
67
+ "{{count}} lane(s) arrived with no diagram, so Labre split the pool into equal bands. The proportions are Labre's and not the file's.",
68
+ ],
69
+ undrawnTrigger: [
70
+ 'com.labre.bpmn.import.remark.undrawn-trigger',
71
+ '<{{tag}}> names its trigger by reference to "{{ref}}", which Labre does not draw. The event was kept whole rather than drawn as a plain one.',
72
+ ],
73
+ inventedNodeLayout: [
74
+ 'com.labre.bpmn.import.remark.invented-node-layout',
75
+ "<{{tag}}> arrived with no diagram, so Labre placed it beside the drawing. Its position is Labre's and not the file's.",
76
+ ],
77
+ singleEndedFlow: [
78
+ 'com.labre.bpmn.import.remark.single-ended-flow',
79
+ '<{{tag}}> names only one of its two ends, so there is no arrow to draw between them. It was left out.',
80
+ ],
81
+ danglingFlowEnd: [
82
+ 'com.labre.bpmn.import.remark.dangling-flow-end',
83
+ '<{{tag}}> runs to {{ends}}, not drawn on this canvas. The flow is kept whole beside them rather than drawn with a loose end.',
84
+ ],
85
+ undeclaredShape: [
86
+ 'com.labre.bpmn.import.remark.undeclared-shape',
87
+ 'The diagram draws "{{id}}", which the file does not declare. The shape is kept, and nothing is drawn for it.',
88
+ ],
89
+ noProcessResidue: [
90
+ 'com.labre.bpmn.import.remark.no-process-residue',
91
+ 'This file declares {{count}} root element(s) and no process to draw, so there was no artefact for them to be kept on. Nothing was imported.',
92
+ ],
93
+ laneDrift: [
94
+ 'com.labre.bpmn.import.remark.lane-drift',
95
+ 'The file lists this artefact in the lane "{{listedLane}}" and draws it in "{{drawnLane}}". Labre reads the drawing: a lane holds what is drawn inside it.',
96
+ ],
97
+ inventedRouting: [
98
+ 'com.labre.bpmn.import.remark.invented-routing',
99
+ '{{count}} flow(s) carry an explicit routing in the file. Labre routes a flow between its two ends and re-routes it whenever they move, so their bend points are not kept.',
100
+ ],
101
+ };
102
+ /**
103
+ * The reader's own refusals, as `[key, English]` pairs — thrown via
104
+ * {@link InterchangeImportError}, never returned, because none of these say
105
+ * "this is a file I can partly read" (see `parseDefinitions` and the
106
+ * choreography refusal, below). Same shape and the same reason as
107
+ * {@link BPMN_IMPORT_REMARKS}: one entry per shape of refusal, `{{name}}`
108
+ * holes filled by `InterchangeImportError.messageParams`.
109
+ */
110
+ export const BPMN_IMPORT_ERRORS = {
111
+ malformedXml: [
112
+ 'com.labre.bpmn.import.error.malformed-xml',
113
+ 'This file is not well-formed XML, so no BPMN can be read out of it: {{detail}}',
114
+ ],
115
+ notDefinitions: [
116
+ 'com.labre.bpmn.import.error.not-definitions',
117
+ 'A BPMN file opens on <definitions>; this one opens on <{{tag}}>.',
118
+ ],
119
+ wrongNamespace: [
120
+ 'com.labre.bpmn.import.error.wrong-namespace',
121
+ 'This <definitions> is in "{{namespace}}", not in BPMN 2.0\'s ("{{bpmnNamespace}}"). A DMN decision model and a BPMN process open on the same element name and are not the same file.',
122
+ ],
123
+ declinedRootKind: [
124
+ 'com.labre.bpmn.import.error.declined-root-kind',
125
+ 'This file is a BPMN {{kind}}, which Labre does not draw. Only a process or a collaboration can be imported.',
126
+ ],
40
127
  };
41
128
  /**
42
129
  * A BPMN 2.0 interchange document, read as a board — the inverse of `export.ts`
@@ -223,6 +310,25 @@ export const BPMN_QUARANTINE_REASON = {
223
310
  'self-contained set), so writing this back would claim a resolution of ' +
224
311
  'another document that never happened.',
225
312
  };
313
+ /**
314
+ * {@link BPMN_QUARANTINE_REASON}'s own i18n keys, one per reason.
315
+ *
316
+ * A SEPARATE table rather than folding the key into each reason string
317
+ * (`BPMN_IMPORT_REMARKS`'s `[key, english]` shape): `BPMN_QUARANTINE_REASON`'s
318
+ * plain strings are ALSO written verbatim into
319
+ * `ForeignInterchange.quarantined[].reason` — a DATA field the reader hands
320
+ * the writer, never re-emitted, never shown, and pinned in
321
+ * `import.unit.spec.ts` as the literal English text — so it cannot become a
322
+ * tuple without breaking that contract. Only the REPORT note (`messageKey`
323
+ * below) is resolved through the host's catalogue; the carried `reason` data
324
+ * stays English, exactly as it always has.
325
+ */
326
+ export const BPMN_QUARANTINE_REASON_KEY = {
327
+ colour: 'com.labre.bpmn.import.quarantine.colour',
328
+ expanded: 'com.labre.bpmn.import.quarantine.expanded',
329
+ nestedLanes: 'com.labre.bpmn.import.quarantine.nested-lanes',
330
+ imported: 'com.labre.bpmn.import.quarantine.imported',
331
+ };
226
332
  /**
227
333
  * The children of an activity that are NOT its body.
228
334
  *
@@ -344,21 +450,32 @@ function parseDefinitions(source) {
344
450
  const doc = new DOMParser().parseFromString(source, 'application/xml');
345
451
  const error = doc.querySelector('parsererror');
346
452
  if (error) {
347
- throw new Error(`This file is not well-formed XML, so no BPMN can be read out of it: ` +
348
- `${(error.textContent ?? '').trim().slice(0, 200)}`);
453
+ const detail = (error.textContent ?? '').trim().slice(0, 200);
454
+ throw new InterchangeImportError(`This file is not well-formed XML, so no BPMN can be read out of it: ${detail}`, {
455
+ messageKey: BPMN_IMPORT_ERRORS.malformedXml[0],
456
+ messageParams: { detail },
457
+ });
349
458
  }
350
459
  const root = doc.documentElement;
351
460
  if (!root || root.localName !== 'definitions') {
352
- throw new Error(`A BPMN file opens on <definitions>; this one opens on <${root?.localName ?? 'nothing'}>.`);
461
+ const tag = root?.localName ?? 'nothing';
462
+ throw new InterchangeImportError(`A BPMN file opens on <definitions>; this one opens on <${tag}>.`, {
463
+ messageKey: BPMN_IMPORT_ERRORS.notDefinitions[0],
464
+ messageParams: { tag },
465
+ });
353
466
  }
354
467
  // The NAMESPACE, not the element name — `<definitions>` is also the root of a
355
468
  // DMN decision model, and of anything else built on the same OMG scaffolding.
356
469
  // Without this a `.dmn` imports as an empty board, which is exactly the
357
470
  // "three zeroes claiming an empty process" this reader refuses to return.
358
471
  if (root.namespaceURI !== BPMN_NS.model) {
359
- throw new Error(`This <definitions> is in "${root.namespaceURI ?? 'no namespace'}", not ` +
472
+ const namespace = root.namespaceURI ?? 'no namespace';
473
+ throw new InterchangeImportError(`This <definitions> is in "${namespace}", not ` +
360
474
  `in BPMN 2.0's ("${BPMN_NS.model}"). A DMN decision model and a BPMN ` +
361
- `process open on the same element name and are not the same file.`);
475
+ `process open on the same element name and are not the same file.`, {
476
+ messageKey: BPMN_IMPORT_ERRORS.wrongNamespace[0],
477
+ messageParams: { namespace, bpmnNamespace: BPMN_NS.model },
478
+ });
362
479
  }
363
480
  return root;
364
481
  }
@@ -612,8 +729,12 @@ export function importBpmnXml(source, context = {}) {
612
729
  if (processes.length === 0 && collaborations.length === 0) {
613
730
  const declined = roots.find(root => ['choreography', 'globalChoreographyTask', 'conversation'].includes(root.localName));
614
731
  if (declined) {
615
- throw new Error(`This file is a BPMN ${declined.localName}, which Labre does not draw. ` +
616
- `Only a process or a collaboration can be imported.`);
732
+ const kind = declined.localName;
733
+ throw new InterchangeImportError(`This file is a BPMN ${kind}, which Labre does not draw. ` +
734
+ `Only a process or a collaboration can be imported.`, {
735
+ messageKey: BPMN_IMPORT_ERRORS.declinedRootKind[0],
736
+ messageParams: { kind },
737
+ });
617
738
  }
618
739
  }
619
740
  /** `categoryValue` id → the label a `group` pointing at it wears. */
@@ -637,9 +758,9 @@ export function importBpmnXml(source, context = {}) {
637
758
  kind: 'substituted-id',
638
759
  sourceId,
639
760
  element,
640
- message: `Two elements in this file share the id "${sourceId}", which BPMN ` +
641
- `requires to be unique across a document. Both were imported; the ` +
642
- `second will be written back under an id Labre mints.`,
761
+ messageKey: BPMN_IMPORT_REMARKS.duplicateId[0],
762
+ message: `Two elements in this file share the id "${sourceId}", which BPMN requires to be unique across a document. Both were imported; the second will be written back under an id Labre mints.`,
763
+ messageParams: { id: sourceId },
643
764
  });
644
765
  }
645
766
  seenSourceIds.add(sourceId);
@@ -671,9 +792,9 @@ export function importBpmnXml(source, context = {}) {
671
792
  kind: 'carried',
672
793
  element: child.nodeName,
673
794
  sourceId,
674
- message: `<${child.nodeName}> has no Labre artefact, so it is kept verbatim on ` +
675
- `the nearest element that has one. It is not drawn, and no validation ` +
676
- `rule sees it.`,
795
+ messageKey: BPMN_IMPORT_REMARKS.carriedChild[0],
796
+ message: `<${child.nodeName}> has no Labre artefact, so it is kept verbatim on the nearest element that has one. It is not drawn, and no validation rule sees it.`,
797
+ messageParams: { tag: child.nodeName },
677
798
  });
678
799
  };
679
800
  /** One diagram fragment, kept under the scope of what it DRAWS. */
@@ -684,13 +805,23 @@ export function importBpmnXml(source, context = {}) {
684
805
  };
685
806
  carried++;
686
807
  };
687
- const quarantine = (payload, fragment, reason, entry) => {
808
+ const quarantine = (payload, fragment, reason, entry,
809
+ // The reason's OWN key ({@link BPMN_QUARANTINE_REASON_KEY}) — separate
810
+ // from `reason` because that string is also written into
811
+ // `payload.quarantined[].reason` (data, never translated); only the
812
+ // REPORT note below carries the key.
813
+ reasonKey) => {
688
814
  payload.quarantined = [
689
815
  ...(payload.quarantined ?? []),
690
816
  { fragment, reason },
691
817
  ];
692
818
  quarantined++;
693
- note({ kind: 'quarantined', ...entry, message: reason });
819
+ note({
820
+ kind: 'quarantined',
821
+ ...entry,
822
+ message: reason,
823
+ ...(reasonKey ? { messageKey: reasonKey } : {}),
824
+ });
694
825
  };
695
826
  /** Every attribute the reader does not model — colours quarantined (D5). */
696
827
  const sortAttributes = (element, payload, scope, sourceId, understood = READ_ATTRS[element.localName] ??
@@ -701,7 +832,7 @@ export function importBpmnXml(source, context = {}) {
701
832
  if (understood.includes(attr.name))
702
833
  continue;
703
834
  if (isColourAttr(attr)) {
704
- quarantine(payload, `${attr.name}="${escapeAttr(attr.value)}"`, BPMN_QUARANTINE_REASON.colour, { sourceId, element: attr.name });
835
+ quarantine(payload, `${attr.name}="${escapeAttr(attr.value)}"`, BPMN_QUARANTINE_REASON.colour, { sourceId, element: attr.name }, BPMN_QUARANTINE_REASON_KEY.colour);
705
836
  continue;
706
837
  }
707
838
  carryAttr(payload, scope, attr.name, attr.value);
@@ -792,12 +923,14 @@ export function importBpmnXml(source, context = {}) {
792
923
  if (process)
793
924
  poolOfProcess.set(process, draft);
794
925
  if (!bounds) {
926
+ const name = draft.props.name || 'unnamed';
795
927
  note({
796
928
  kind: 'invented-layout',
797
929
  sourceId,
798
930
  element: 'participant',
799
- message: `The participant "${draft.props.name || 'unnamed'}" arrived with no ` +
800
- `diagram, so Labre placed its pool beside the drawing.`,
931
+ messageKey: BPMN_IMPORT_REMARKS.inventedPoolLayout[0],
932
+ message: `The participant "${name}" arrived with no diagram, so Labre placed its pool beside the drawing.`,
933
+ messageParams: { name },
801
934
  });
802
935
  }
803
936
  }
@@ -887,7 +1020,7 @@ export function importBpmnXml(source, context = {}) {
887
1020
  const name = attrOf(lane, 'name') ?? '';
888
1021
  const nested = modelChild(lane, 'childLaneSet');
889
1022
  if (nested) {
890
- quarantine(pool.payload, fragmentOf(nested), BPMN_QUARANTINE_REASON.nestedLanes, { sourceId, element: 'childLaneSet' });
1023
+ quarantine(pool.payload, fragmentOf(nested), BPMN_QUARANTINE_REASON.nestedLanes, { sourceId, element: 'childLaneSet' }, BPMN_QUARANTINE_REASON_KEY.nestedLanes);
891
1024
  // A lane holding a child set is not a band Labre paints — its LEAVES
892
1025
  // are — so the shape drawing it describes a subdivision the flat pool
893
1026
  // does not have. Accounted for by the quarantine, and kept out of the
@@ -959,9 +1092,9 @@ export function importBpmnXml(source, context = {}) {
959
1092
  kind: 'invented-layout',
960
1093
  sourceId: attrOf(laneSet, 'id'),
961
1094
  element: 'laneSet',
962
- message: `${bands.length === 1 ? 'This lane' : `Some of these ${bands.length} lanes`} ` +
963
- `arrived with no diagram, so Labre split the pool into equal bands. ` +
964
- `The proportions are Labre's and not the file's.`,
1095
+ messageKey: BPMN_IMPORT_REMARKS.inventedLaneLayout[0],
1096
+ message: `${bands.length} lane(s) arrived with no diagram, so Labre split the pool into equal bands. The proportions are Labre's and not the file's.`,
1097
+ messageParams: { count: bands.length },
965
1098
  });
966
1099
  }
967
1100
  // Bands that do not tile the pool are still only WEIGHTS here — Labre lays
@@ -1035,13 +1168,14 @@ export function importBpmnXml(source, context = {}) {
1035
1168
  // whole into the carried branch below rather than onto the canvas claiming
1036
1169
  // something the file did not say.
1037
1170
  if (kind === undefined && triggerRef !== undefined) {
1171
+ const ref = triggerRef.textContent?.trim() ?? '';
1038
1172
  note({
1039
1173
  kind: 'warning',
1040
1174
  sourceId,
1041
1175
  element: local,
1042
- message: `<${local}> names its trigger by reference to ` +
1043
- `"${triggerRef.textContent?.trim() ?? ''}", which Labre does not ` +
1044
- `draw. The event was kept whole rather than drawn as a plain one.`,
1176
+ messageKey: BPMN_IMPORT_REMARKS.undrawnTrigger[0],
1177
+ message: `<${local}> names its trigger by reference to "${ref}", which Labre does not draw. The event was kept whole rather than drawn as a plain one.`,
1178
+ messageParams: { tag: local, ref },
1045
1179
  });
1046
1180
  }
1047
1181
  if (kind === undefined) {
@@ -1100,7 +1234,7 @@ export function importBpmnXml(source, context = {}) {
1100
1234
  continue;
1101
1235
  }
1102
1236
  if (expanded && isModel(child) && !NOT_A_BODY.has(child.localName)) {
1103
- quarantine(payload, fragmentOf(child), BPMN_QUARANTINE_REASON.expanded, { sourceId, element: child.nodeName });
1237
+ quarantine(payload, fragmentOf(child), BPMN_QUARANTINE_REASON.expanded, { sourceId, element: child.nodeName }, BPMN_QUARANTINE_REASON_KEY.expanded);
1104
1238
  // The body's diagram goes with the body, all the way down. A shape left
1105
1239
  // behind here is an ORPHAN — nothing declares what it draws any more —
1106
1240
  // so the residue sweep at the end of this reader would pick it up and
@@ -1152,8 +1286,9 @@ export function importBpmnXml(source, context = {}) {
1152
1286
  kind: 'invented-layout',
1153
1287
  sourceId,
1154
1288
  element: local,
1155
- message: `<${local}> arrived with no diagram, so Labre placed it beside the ` +
1156
- `drawing. Its position is Labre's and not the file's.`,
1289
+ messageKey: BPMN_IMPORT_REMARKS.inventedNodeLayout[0],
1290
+ message: `<${local}> arrived with no diagram, so Labre placed it beside the drawing. Its position is Labre's and not the file's.`,
1291
+ messageParams: { tag: local },
1157
1292
  });
1158
1293
  }
1159
1294
  };
@@ -1188,8 +1323,9 @@ export function importBpmnXml(source, context = {}) {
1188
1323
  kind: 'warning',
1189
1324
  sourceId,
1190
1325
  element: local,
1191
- message: `<${local}> names only one of its two ends, so there is no arrow ` +
1192
- `to draw between them. It was left out.`,
1326
+ messageKey: BPMN_IMPORT_REMARKS.singleEndedFlow[0],
1327
+ message: `<${local}> names only one of its two ends, so there is no arrow to draw between them. It was left out.`,
1328
+ messageParams: { tag: local },
1193
1329
  });
1194
1330
  continue;
1195
1331
  }
@@ -1208,16 +1344,17 @@ export function importBpmnXml(source, context = {}) {
1208
1344
  if (edgeDi) {
1209
1345
  carryDi(host.payload, sourceId ?? hostScope, fragmentOf(edgeDi.element));
1210
1346
  }
1211
- note({
1212
- kind: 'warning',
1213
- sourceId,
1214
- element: local,
1215
- message: `<${local}> runs to ${dangling.map(end => `"${end}"`).join(' and ')}, ` +
1216
- `which ${dangling.length === 1 ? 'is' : 'are'} not drawn on this ` +
1217
- `canvas. The flow is kept whole beside ` +
1218
- `${dangling.length === 1 ? 'it' : 'them'} rather than drawn with a ` +
1219
- `loose end.`,
1220
- });
1347
+ {
1348
+ const ends = dangling.map(end => `"${end}"`).join(' and ');
1349
+ note({
1350
+ kind: 'warning',
1351
+ sourceId,
1352
+ element: local,
1353
+ messageKey: BPMN_IMPORT_REMARKS.danglingFlowEnd[0],
1354
+ message: `<${local}> runs to ${ends}, not drawn on this canvas. The flow is kept whole beside them rather than drawn with a loose end.`,
1355
+ messageParams: { tag: local, ends },
1356
+ });
1357
+ }
1221
1358
  continue;
1222
1359
  }
1223
1360
  claim(sourceId, local);
@@ -1340,7 +1477,7 @@ export function importBpmnXml(source, context = {}) {
1340
1477
  // file, so writing an `<import>` back would claim a resolution we never
1341
1478
  // made.
1342
1479
  if (isModel(root) && root.localName === 'import') {
1343
- quarantine(host.payload, fragmentOf(root), BPMN_QUARANTINE_REASON.imported, { element: 'import' });
1480
+ quarantine(host.payload, fragmentOf(root), BPMN_QUARANTINE_REASON.imported, { element: 'import' }, BPMN_QUARANTINE_REASON_KEY.imported);
1344
1481
  continue;
1345
1482
  }
1346
1483
  // Not quarantine: the file telling us our reading of it may be wrong,
@@ -1376,8 +1513,9 @@ export function importBpmnXml(source, context = {}) {
1376
1513
  kind: 'warning',
1377
1514
  sourceId: target,
1378
1515
  element: shape.localName,
1379
- message: `The diagram draws "${target}", which the file does not declare. ` +
1380
- `The shape is kept, and nothing is drawn for it.`,
1516
+ messageKey: BPMN_IMPORT_REMARKS.undeclaredShape[0],
1517
+ message: `The diagram draws "${target}", which the file does not declare. The shape is kept, and nothing is drawn for it.`,
1518
+ messageParams: { id: target },
1381
1519
  });
1382
1520
  }
1383
1521
  }
@@ -1387,11 +1525,9 @@ export function importBpmnXml(source, context = {}) {
1387
1525
  // process and no participant has none.
1388
1526
  note({
1389
1527
  kind: 'warning',
1390
- message: `This file declares ${residue.length} root ` +
1391
- `${residue.length === 1 ? 'element' : 'elements'} and no process to ` +
1392
- `draw, so there was no artefact for ` +
1393
- `${residue.length === 1 ? 'it' : 'them'} to be kept on. Nothing was ` +
1394
- `imported.`,
1528
+ messageKey: BPMN_IMPORT_REMARKS.noProcessResidue[0],
1529
+ message: `This file declares ${residue.length} root element(s) and no process to draw, so there was no artefact for them to be kept on. Nothing was imported.`,
1530
+ messageParams: { count: residue.length },
1395
1531
  });
1396
1532
  }
1397
1533
  /* ── Lane membership: checked against the drawing, never stored (D3) ─ */
@@ -1413,9 +1549,12 @@ export function importBpmnXml(source, context = {}) {
1413
1549
  kind: 'warning',
1414
1550
  sourceId: ref,
1415
1551
  element: 'flowNodeRef',
1416
- message: `The file lists this artefact in the lane "${band.lane.name}" and ` +
1417
- `draws it in "${drawnIn.lane.name}". Labre reads the drawing: a ` +
1418
- `lane holds what is drawn inside it.`,
1552
+ messageKey: BPMN_IMPORT_REMARKS.laneDrift[0],
1553
+ message: `The file lists this artefact in the lane "${band.lane.name ?? ''}" and draws it in "${drawnIn.lane.name ?? ''}". Labre reads the drawing: a lane holds what is drawn inside it.`,
1554
+ messageParams: {
1555
+ listedLane: band.lane.name ?? '',
1556
+ drawnLane: drawnIn.lane.name ?? '',
1557
+ },
1419
1558
  });
1420
1559
  }
1421
1560
  }
@@ -1425,11 +1564,9 @@ export function importBpmnXml(source, context = {}) {
1425
1564
  if (explicitRoutes > 0) {
1426
1565
  note({
1427
1566
  kind: 'invented-layout',
1428
- message: `${explicitRoutes} ${explicitRoutes === 1 ? 'flow carries' : 'flows carry'} ` +
1429
- `an explicit routing in the file. Labre routes a flow between its two ` +
1430
- `ends and re-routes it whenever they move, so ` +
1431
- `${explicitRoutes === 1 ? 'its bend points are' : 'their bend points are'} ` +
1432
- `not kept.`,
1567
+ messageKey: BPMN_IMPORT_REMARKS.inventedRouting[0],
1568
+ message: `${explicitRoutes} flow(s) carry an explicit routing in the file. Labre routes a flow between its two ends and re-routes it whenever they move, so their bend points are not kept.`,
1569
+ messageParams: { count: explicitRoutes },
1433
1570
  });
1434
1571
  }
1435
1572
  /* ── Out ───────────────────────────────────────────────────────────── */
@@ -48,6 +48,40 @@ export declare const BPMN_XML_FORMAT: InterchangeFormat;
48
48
  * gives a centre inside two overlapping pools to the FIRST one, and the audit's
49
49
  * `attribute()` does the same. Sorting here would make the export disagree with
50
50
  * the badge the user can see.
51
+ *
52
+ * ## The filter is the ROLE, not the class
53
+ *
54
+ * A `BpmnNodeElementModel` is a shape with a `kind`; the ROLE is the author's
55
+ * statement that this circle IS the start of the process (`docs/adr/0010`, and
56
+ * C4's `isC4Node` before it). The distinction became load-bearing the day the
57
+ * pool grew a legend: a legend swatch is a real `bpmnNode` with a real `kind`
58
+ * and deliberately NO role, so a class-based filter would have written eighteen
59
+ * ghost `<task>` / `<startEvent>` / `<dataObject>` elements into the
60
+ * participant's process and broken the bpmn.io round trip the pack is for. The
61
+ * connectors have always been filtered this way (`EDGE_ELEMENT[connector.role]`
62
+ * in `export.ts`); this is the nodes and the pools catching up.
63
+ *
64
+ * ## What it costs, and the PO took that cost
65
+ *
66
+ * The pack shipped on 2026-06-13 and roles on 2026-08-26, with nothing
67
+ * backfilled (`roles.ts` says so in as many words). A process drawn between
68
+ * those two dates carries no role on anything and therefore no longer exports.
69
+ * Arbitrated on 2026-09-17: the alternative is an interchange file that cannot
70
+ * be trusted, which is worse than one that is not written.
71
+ *
72
+ * ## …and what it now SAYS
73
+ *
74
+ * The same ruling of 2026-09-17 added the other half: everything inside a
75
+ * pool's perimeter leaves in the board's generic SVG export (ADR 0025, R34),
76
+ * and only the roled artefacts leave in the `.bpmn`. That asymmetry is wanted;
77
+ * being silent about it was not. So the role-less things drawn INSIDE a pool
78
+ * are counted here — this is the only place that sees what was not picked —
79
+ * and `exportBpmnXmlWithWarnings` turns the count into one warning.
80
+ *
81
+ * Connectors are deliberately NOT counted: a neutral arrow states nothing
82
+ * (`docs/adr/0010`), so there is nothing to lose, which is the reason
83
+ * `export.ts` has always dropped one in silence. Neither is a generated legend
84
+ * — see {@link legendMemberIds}.
51
85
  */
52
86
  export declare function bpmnBoardFrom(elements: readonly GfxPrimitiveElementModel[]): BpmnExportBoard;
53
87
  /**
@@ -1,7 +1,10 @@
1
- import { interchangeCapabilityId, parseSvgSketch, SVG_SKETCH_EXTENSION, SVG_SKETCH_FORMAT_ID, SVG_SKETCH_MIME, } from '@formicoidea/labre-core/blocks/surface';
1
+ import { interchangeCapabilityId, LEGEND_ROLE, parseSvgSketch, SVG_SKETCH_EXTENSION, SVG_SKETCH_FORMAT_ID, SVG_SKETCH_MIME, } from '@formicoidea/labre-core/blocks/surface';
2
2
  import { BpmnNodeElementModel, BpmnPoolElementModel, ConnectorElementModel, } from '@formicoidea/labre-core/model';
3
+ import { fillPlaceholders } from '@formicoidea/labre-core/shared/services';
3
4
  import { BPMN_FORMAT_ID, exportBpmnXmlWithWarnings, } from './export.js';
5
+ import { bpmnPoolOf } from './facts.js';
4
6
  import { importBpmnXml } from './import.js';
7
+ import { BPMN_ROLE, BPMN_ROLE_OF_KIND } from './roles.js';
5
8
  /**
6
9
  * BPMN's entries in the interchange registry (`docs/adr/0012`, P1).
7
10
  *
@@ -48,6 +51,41 @@ export const BPMN_XML_FORMAT = {
48
51
  mime: BPMN_XML_MIME,
49
52
  };
50
53
  /* ── Pure board helpers ───────────────────────────────────────────────── */
54
+ /**
55
+ * Everything a generated legend is made of: the group the legend gesture stamps
56
+ * with {@link LEGEND_ROLE}, and everything under it.
57
+ *
58
+ * A legend is drawn INSIDE the board it documents (`createBoardLegend`) and is
59
+ * made, on purpose, of role-less shapes, texts and swatches — so without this
60
+ * it would be reported as fourteen things the `.bpmn` left out, which is
61
+ * exactly the surprise that warning exists to prevent. The group's ROLE is what
62
+ * it is read by: nothing here matches a title, a framework name or a box.
63
+ *
64
+ * Read defensively, like every other walk over stored data in this framework:
65
+ * the children came out of a Y.Map and are whatever a peer wrote.
66
+ */
67
+ function legendMemberIds(elements) {
68
+ const byId = new Map(elements.map(element => [element.id, element]));
69
+ const members = new Set();
70
+ const walk = (element) => {
71
+ if (members.has(element.id))
72
+ return;
73
+ members.add(element.id);
74
+ const children = element.childIds;
75
+ if (!Array.isArray(children))
76
+ return;
77
+ for (const id of children) {
78
+ const child = typeof id === 'string' ? byId.get(id) : undefined;
79
+ if (child)
80
+ walk(child);
81
+ }
82
+ };
83
+ for (const element of elements) {
84
+ if (element.role === LEGEND_ROLE)
85
+ walk(element);
86
+ }
87
+ return members;
88
+ }
51
89
  /**
52
90
  * The artefacts the exporter speaks about, picked out of a surface's elements
53
91
  * and kept in the order they were given.
@@ -56,20 +94,75 @@ export const BPMN_XML_FORMAT = {
56
94
  * gives a centre inside two overlapping pools to the FIRST one, and the audit's
57
95
  * `attribute()` does the same. Sorting here would make the export disagree with
58
96
  * the badge the user can see.
97
+ *
98
+ * ## The filter is the ROLE, not the class
99
+ *
100
+ * A `BpmnNodeElementModel` is a shape with a `kind`; the ROLE is the author's
101
+ * statement that this circle IS the start of the process (`docs/adr/0010`, and
102
+ * C4's `isC4Node` before it). The distinction became load-bearing the day the
103
+ * pool grew a legend: a legend swatch is a real `bpmnNode` with a real `kind`
104
+ * and deliberately NO role, so a class-based filter would have written eighteen
105
+ * ghost `<task>` / `<startEvent>` / `<dataObject>` elements into the
106
+ * participant's process and broken the bpmn.io round trip the pack is for. The
107
+ * connectors have always been filtered this way (`EDGE_ELEMENT[connector.role]`
108
+ * in `export.ts`); this is the nodes and the pools catching up.
109
+ *
110
+ * ## What it costs, and the PO took that cost
111
+ *
112
+ * The pack shipped on 2026-06-13 and roles on 2026-08-26, with nothing
113
+ * backfilled (`roles.ts` says so in as many words). A process drawn between
114
+ * those two dates carries no role on anything and therefore no longer exports.
115
+ * Arbitrated on 2026-09-17: the alternative is an interchange file that cannot
116
+ * be trusted, which is worse than one that is not written.
117
+ *
118
+ * ## …and what it now SAYS
119
+ *
120
+ * The same ruling of 2026-09-17 added the other half: everything inside a
121
+ * pool's perimeter leaves in the board's generic SVG export (ADR 0025, R34),
122
+ * and only the roled artefacts leave in the `.bpmn`. That asymmetry is wanted;
123
+ * being silent about it was not. So the role-less things drawn INSIDE a pool
124
+ * are counted here — this is the only place that sees what was not picked —
125
+ * and `exportBpmnXmlWithWarnings` turns the count into one warning.
126
+ *
127
+ * Connectors are deliberately NOT counted: a neutral arrow states nothing
128
+ * (`docs/adr/0010`), so there is nothing to lose, which is the reason
129
+ * `export.ts` has always dropped one in silence. Neither is a generated legend
130
+ * — see {@link legendMemberIds}.
59
131
  */
60
132
  export function bpmnBoardFrom(elements) {
61
133
  const pools = [];
62
134
  const nodes = [];
63
135
  const connectors = [];
136
+ const roleless = [];
64
137
  for (const element of elements) {
65
- if (element instanceof BpmnPoolElementModel)
66
- pools.push(element);
67
- else if (element instanceof BpmnNodeElementModel)
68
- nodes.push(element);
69
- else if (element instanceof ConnectorElementModel)
138
+ if (element instanceof BpmnPoolElementModel) {
139
+ if (element.role === BPMN_ROLE.pool)
140
+ pools.push(element);
141
+ else
142
+ roleless.push(element);
143
+ }
144
+ else if (element instanceof BpmnNodeElementModel) {
145
+ if (element.role === BPMN_ROLE_OF_KIND[element.kind])
146
+ nodes.push(element);
147
+ else
148
+ roleless.push(element);
149
+ }
150
+ else if (element instanceof ConnectorElementModel) {
70
151
  connectors.push(element);
152
+ }
153
+ else {
154
+ roleless.push(element);
155
+ }
71
156
  }
72
- return { pools, nodes, connectors };
157
+ // Against the pools that are actually WRITTEN: "inside the pool" has to mean
158
+ // inside a pool the file has, or the sentence names a perimeter the reader of
159
+ // the `.bpmn` cannot see. `bpmnPoolOf` is the same whole-containment test the
160
+ // audit and the serializer use, so what is reported left out is what the rest
161
+ // of the framework agrees is in there.
162
+ const legend = legendMemberIds(elements);
163
+ const leftOut = roleless.filter(element => !legend.has(element.id) &&
164
+ bpmnPoolOf(pools, element.elementBound) !== null).length;
165
+ return { pools, nodes, connectors, ...(leftOut > 0 ? { leftOut } : {}) };
73
166
  }
74
167
  /**
75
168
  * A name a file system will accept, minus the extension.
@@ -115,7 +208,18 @@ const runBpmnXmlExport = (elements, context) => {
115
208
  text,
116
209
  filename: `${name}${BPMN_XML_EXTENSION}`,
117
210
  mime: BPMN_XML_MIME,
118
- ...(warnings.length > 0 ? { warnings } : {}),
211
+ // `InterchangeExportResult.warnings` is `readonly string[]` — a seam this
212
+ // capability shares with every other format and does not own — so each
213
+ // KEYED warning (`BpmnExportWarning`, `export.ts`) is resolved to its
214
+ // English fallback here. This adapter is `std`-free like the exporter it
215
+ // wraps (P3), so it cannot ask a host's catalogue either; the translated
216
+ // command path is `exportBpmnXmlFile` (`actions.ts`), which reads the
217
+ // structured warnings directly instead of going through this capability.
218
+ ...(warnings.length > 0
219
+ ? {
220
+ warnings: warnings.map(w => fillPlaceholders(w.fallback, w.params)),
221
+ }
222
+ : {}),
119
223
  };
120
224
  };
121
225
  /** `bpmn:bpmn:export` — the registry's first capability. */