@uipath/maestro-builder-sdk 6.11.6 → 6.12.1

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.
@@ -2,7 +2,7 @@
2
2
  * bpmn/connector — emit an Integration Service connector service task as a
3
3
  * `bpmn:sendTask` carrying a `uipath:activity` (`Intsvc.ActivityExecution`).
4
4
  *
5
- * The connector DATA (wiring context, path/query/body input split, output schema,
5
+ * The connector DATA (wiring context, per-parameter path/query rows + one body row, output schema,
6
6
  * metadata blob, connection/folder bindings) is exactly what the Flow node and the
7
7
  * Case task carry — all derived from the same `ResolvedConnector` (`Library.resolve`)
8
8
  * — only the ENVELOPE differs: moddle `uipath:*` elements here vs. caseplan JSON in
@@ -49,7 +49,7 @@ export declare function createConnectorTask(ctx: ConnectorCtx, n: ConnectorNode)
49
49
  * the same way a connector activity does, and duplicating this was how the two
50
50
  * families drifted apart in `case/serialize.ts`.
51
51
  */
52
- export declare function connectorBindingRef(ctx: ConnectorCtx, name: string | undefined, resource: string, propertyAttribute: string, displayName: string): {
52
+ export declare function connectorBindingRef(ctx: ConnectorCtx, name: string | undefined, resource: string, propertyAttribute: string, displayName: string, companionKey?: string): {
53
53
  id: string;
54
54
  resourceKey: string;
55
55
  } | undefined;
@@ -8,7 +8,6 @@ export function createConnectorTask(ctx, n) {
8
8
  const rc = ctx.library.resolve(n.key, n.action, n.version, n.object);
9
9
  const outVars = connectorOutputVars(n);
10
10
  const op = (rc.entry.operation ?? {});
11
- const params = Array.isArray(op.parameters) ? op.parameters : [];
12
11
  // ── uipath:context — the connector wiring ──
13
12
  // `activity` is the primary-key required field (the selected op identity).
14
13
  const ctxInputs = [
@@ -33,33 +32,42 @@ export function createConnectorTask(ctx, n) {
33
32
  ctxInputs.push(ctxInput(m, 'connection', 'string', `=bindings.${connRef.id}`));
34
33
  ctxInputs.push(ctxInput(m, 'resourceKey', 'string', connRef.resourceKey));
35
34
  }
36
- const folderRef = bindingRef(ctx, n.folder, 'Connection', 'folderKey', 'FolderKey');
35
+ const folderRef = bindingRef(ctx, n.folder, 'Connection', 'folderKey', 'FolderKey', connRef?.resourceKey);
37
36
  if (folderRef)
38
37
  ctxInputs.push(ctxInput(m, 'folderKey', 'string', `=bindings.${folderRef.id}`));
39
38
  ctxInputs.push(ctxInput(m, 'objectName', 'string', op.objectName ?? n.action), ctxInput(m, 'method', 'string', transportHttpMethod(rc.httpMethod)), ctxInput(m, 'path', 'string', rc.endpoint), jsonInput(m, 'metadata', undefined, connectorMetadata(rc, op)));
40
- // ── request inputs — split declared params into path/query, rest is body ──
41
- const pathParameters = {};
42
- const queryParameters = {};
43
- // BPMN copies these values into the emitted body verbatim, so an unresolved
44
- // lookup would ship as `{"__lookup": …}` where the connector wants an id.
45
- // Only Flow reads connectors-local/resolutions.json today.
39
+ // ── request inputs — one ROW per path / query / file parameter, the body as one json row ──
40
+ //
41
+ // This is the shape every real Studio Web export and the Flow→BPMN converter write:
42
+ // `<uipath:input name="entityName" type="string" value="…" target="path"/>`,
43
+ // `<uipath:input name="start" type="string" value="0" target="query"/>`, then a single
44
+ // `<uipath:input name="body" type="json" target="body">{…}</uipath:input>` (57 flat
45
+ // rows and no merged one across the 12 connector nodes of `construction-invoices.bpmn`).
46
+ // The SDK used to merge the parameters into two json rows named `pathParameters` /
47
+ // `queryParameters` with camelCased keys — a shape nothing else on the platform
48
+ // produces, which the graders that compare against a real artifact rejected
49
+ // (`name="send_as" target="query"` expected, `{"sendAs":…}` found) and which hid the
50
+ // parameter names from anything reading the rows by name, the local engine included
51
+ // (`queryParamNamesOf` collects `target="query"` rows BY NAME).
52
+ //
53
+ // The parameter names stay as the library spells them; the location comes from the
54
+ // resolved field (`operation.parameters` for path/query, the multipart `file` part),
55
+ // and an input the library does not declare goes to the body, where an untagged
56
+ // input has always gone.
57
+ const fieldsByName = new Map(rc.fields.map((f) => [f.name, f]));
58
+ const paramRows = [];
59
+ const body = {};
46
60
  for (const [name, value] of Object.entries(n.inputs ?? {})) {
61
+ // BPMN copies these values into the emitted rows verbatim, so an unresolved
62
+ // lookup would ship as `{"__lookup": …}` where the connector wants an id.
63
+ // Only Flow reads connectors-local/resolutions.json today.
47
64
  refuseLookupToken(value, 'bpmn', name);
48
- }
49
- const body = { ...n.inputs };
50
- for (const p of params) {
51
- const name = String(p.name ?? '');
52
- if (!(name in body))
65
+ const location = fieldsByName.get(name)?.location ?? 'body';
66
+ if (location === 'body') {
67
+ body[name] = value;
53
68
  continue;
54
- const camel = toCamel(name);
55
- if (p.type === 'path') {
56
- pathParameters[camel] = body[name];
57
- delete body[name];
58
- }
59
- else if (p.type === 'query') {
60
- queryParameters[camel] = body[name];
61
- delete body[name];
62
69
  }
70
+ paramRows.push(paramInput(m, name, location, value));
63
71
  }
64
72
  // ── outputs — response schema + standard Error ──
65
73
  const responseSchema = rc.entry.outputSchema
@@ -72,11 +80,7 @@ export function createConnectorTask(ctx, n) {
72
80
  skipCondition: n.skipCondition,
73
81
  type: m.create('uipath:Type', { value: 'Intsvc.ActivityExecution', version: 'v1' }),
74
82
  context: m.create('uipath:Context', { input: ctxInputs }),
75
- input: [
76
- jsonInput(m, 'pathParameters', 'pathParameters', pathParameters),
77
- jsonInput(m, 'queryParameters', 'queryParameters', queryParameters),
78
- jsonInput(m, 'body', 'body', body),
79
- ],
83
+ input: [...paramRows, jsonInput(m, 'body', 'body', body)],
80
84
  output: [
81
85
  m.create('uipath:Output', {
82
86
  name: 'response', type: 'jsonSchema', source: '=response', var: outVars.response,
@@ -104,7 +108,27 @@ export function createConnectorTask(ctx, n) {
104
108
  function ctxInput(m, name, type, value) {
105
109
  return m.create('uipath:Input', { name, type, value: String(value ?? '') });
106
110
  }
107
- /** A json `uipath:input` — CDATA body, optionally `target`-tagged (body/path/query). */
111
+ /**
112
+ * One path / query / file parameter as its own `uipath:input` row, `target`-tagged with
113
+ * where it goes. A scalar is written as the `value` attribute with `type="string"` —
114
+ * the corpus writes `start="0"` and `isAscending="false"` that way, never typed — and
115
+ * a structured value falls back to a json body so nothing is flattened to
116
+ * `[object Object]`. A multipart `file` part is `type="file" target="file"`, the shape
117
+ * the Flow→BPMN converter writes for an upload.
118
+ */
119
+ function paramInput(m, name, location, value) {
120
+ const target = location === 'multipart' ? 'file' : location;
121
+ if (value !== null && typeof value === 'object') {
122
+ return m.create('uipath:Input', { name, type: 'json', body: JSON.stringify(value), target });
123
+ }
124
+ return m.create('uipath:Input', {
125
+ name,
126
+ type: location === 'multipart' ? 'file' : 'string',
127
+ value: String(value ?? ''),
128
+ target,
129
+ });
130
+ }
131
+ /** A json `uipath:input` — CDATA body, optionally `target`-tagged (`body`). */
108
132
  function jsonInput(m, name, target, obj) {
109
133
  const props = { name, type: 'json', body: JSON.stringify(obj ?? {}) };
110
134
  if (target)
@@ -119,18 +143,34 @@ function jsonInput(m, name, target, obj) {
119
143
  * the same way a connector activity does, and duplicating this was how the two
120
144
  * families drifted apart in `case/serialize.ts`.
121
145
  */
122
- export function connectorBindingRef(ctx, name, resource, propertyAttribute, displayName) {
123
- return bindingRef(ctx, name, resource, propertyAttribute, displayName);
146
+ export function connectorBindingRef(ctx, name, resource, propertyAttribute, displayName, companionKey) {
147
+ return bindingRef(ctx, name, resource, propertyAttribute, displayName, companionKey);
124
148
  }
125
- /** Resolve a symbolic connection/folder to a process-level binding (collected once),
149
+ /**
150
+ * Resolve a symbolic connection/folder to a process-level binding (collected once),
126
151
  * returning its id + resourceKey for the `=bindings.<id>` reference. Mirrors
127
- * `case/serialize.ts` `bindingRef`. */
128
- function bindingRef(ctx, name, resource, propertyAttribute, displayName) {
152
+ * `case/serialize.ts` `bindingRef`.
153
+ *
154
+ * `companionKey` is for a FOLDER binding: its `resourceKey` is the key of the
155
+ * connection it belongs to, not the folder's own — that is how every real export
156
+ * writes the pair, and `validate` reports `MISSING_BINDING` otherwise (see
157
+ * `folderCompanionKeys` in serialize.ts). A bindings.json entry that spells its own
158
+ * distinct `resourceKey` is left as written.
159
+ */
160
+ function bindingRef(ctx, name, resource, propertyAttribute, displayName, companionKey) {
129
161
  if (!name)
130
162
  return undefined;
131
163
  const entry = ctx.bindings?.entry(name);
132
164
  const id = entry?.id ?? name;
133
- const resourceKey = entry?.resourceKey ?? entry?.default ?? name;
165
+ // `default` is the deploy-time VALUE the runtime resolves, not the author's symbolic
166
+ // name for the binding. An entry that states only a `resourceKey` — which is what a
167
+ // real `bindings.json` written against a tenant looks like — used to fall through to
168
+ // that key; it now has to again, because the bare `name` in that slot is a spelling
169
+ // no platform-written binding ever carries. Sampled over the corpus, every `default`
170
+ // is a GUID, and 299 of 528 real bindings omit the attribute rather than invent one.
171
+ const own = entry?.default ?? entry?.resourceKey ?? name;
172
+ const explicit = entry?.resourceKey;
173
+ const resourceKey = explicit !== undefined && explicit !== own ? explicit : (companionKey ?? explicit ?? own);
134
174
  if (!ctx.bindingSink.has(id)) {
135
175
  ctx.bindingSink.set(id, {
136
176
  id,
@@ -138,7 +178,7 @@ function bindingRef(ctx, name, resource, propertyAttribute, displayName) {
138
178
  type: 'string',
139
179
  resource,
140
180
  resourceKey,
141
- default: entry?.default ?? resourceKey,
181
+ default: own,
142
182
  propertyAttribute,
143
183
  });
144
184
  }
@@ -194,6 +234,3 @@ function jsonType(t) {
194
234
  default: return 'string';
195
235
  }
196
236
  }
197
- function toCamel(s) {
198
- return s.replace(/[_-](\w)/g, (_, c) => c.toUpperCase());
199
- }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * bpmn/data-service — the wire shape behind `.dataService()`: one UiPath Data Fabric
3
+ * (Data Service) record or file operation as an `Intsvc.ActivityExecution` send task.
4
+ *
5
+ * Why a dedicated helper and not `.connector('uipath-uipath-dataservice', …)`: the
6
+ * connector library ships ONE Data Service operation (`query-entity-records`), so the
7
+ * library-resolving path cannot author a create, an update, a get, a delete or any of
8
+ * the three file operations. Agents fell back to the generic `.activity()` and filled
9
+ * the context by hand, and the run-6 artifacts show what that produced: `objectName`
10
+ * and `operation` both set to `CreateEntityRecordV2` (a spelling the platform does
11
+ * not serve), `recordId` written into the request body instead of a query parameter,
12
+ * a bare UUID JSON-quoted, and no folder binding at all — every one a reason a task
13
+ * that the Flow arm passed failed here.
14
+ *
15
+ * The shapes below are copied from what the Flow→BPMN converter writes for the same
16
+ * operations (the `v1` arm of the same run; `research/bpmn-roundtrip/corpus/` has the
17
+ * Studio Web originals for the query and get forms), which is the shape the graders
18
+ * compare against and the one the platform dispatches on. Two forms exist and both
19
+ * are real:
20
+ *
21
+ * - the **entity form** for create and update — `objectName` is the ENTITY, the path
22
+ * is `/<Entity>` or `/<Entity>/{id}`, and `operation` is `Create` / `Replace`;
23
+ * - the **curated form** for everything else — `objectName` is a curated operation
24
+ * (`GetEntityRecordByIdCurated`, `QueryEntityRecordsCurated`,
25
+ * `DeleteEntityRecordCurated`, `DownloadFileFromRecordField`, …), the entity travels
26
+ * as the `entityName` PATH parameter, and `operation` is the CRUD verb of the
27
+ * underlying HTTP method (`List` for GET, `Create` for POST, `Delete` for DELETE).
28
+ *
29
+ * Every parameter is its own `uipath:input` row, `target`-tagged `path` / `query` /
30
+ * `file`, and typed as the converter types it (`integer` for `start`, `limit`,
31
+ * `expansionLevel`); only the record itself is a json `body` row. Nothing here needs
32
+ * the connector library.
33
+ */
34
+ import type { TypedInputRow } from './typed-node.js';
35
+ /** The connector key the tenant lists Data Fabric under. */
36
+ export declare const DATA_SERVICE_CONNECTOR_KEY = "uipath-uipath-dataservice";
37
+ /** The extension type every Integration Service activity dispatches through. */
38
+ export declare const DATA_SERVICE_ACTIVITY_TYPE = "Intsvc.ActivityExecution";
39
+ /**
40
+ * The operation half of {@link DataServiceOpts} — one of eight, discriminated on `op`.
41
+ *
42
+ * Record values, ids and filters accept `=`-expressions (`'=vars.created.Id'`,
43
+ * `'=js:`dueDate < \'${vars.cutoff}\'`'`) exactly as any other input does.
44
+ */
45
+ export type DataServiceOperation = {
46
+ /** Create one record — `POST /<Entity>`. The response is the created record, `Id` included. */
47
+ op: 'create';
48
+ /** The record's fields, by the entity's field names. */
49
+ record: Record<string, unknown>;
50
+ } | {
51
+ /** Replace one record — `PUT /<Entity>/{id}`. */
52
+ op: 'update';
53
+ /** The record's `Id`. */
54
+ id: string;
55
+ /** The record's fields, by the entity's field names. */
56
+ record: Record<string, unknown>;
57
+ } | {
58
+ /** Read one record by id — the curated `GetEntityRecordByIdCurated`. */
59
+ op: 'get';
60
+ /** The record's `Id`. */
61
+ recordId: string;
62
+ /** How many levels of related records to expand into the response. */
63
+ expansionLevel?: number;
64
+ } | {
65
+ /** Query records with a filter — the curated `QueryEntityRecordsCurated`. */
66
+ op: 'query';
67
+ /** The filter, in the Data Service query language, e.g. `"status = 'Open'"` or `"contractTitle IS NULL"`. Omit for every record. */
68
+ where?: string;
69
+ /** Zero-based offset of the first record. Defaults to `0`. */
70
+ start?: number;
71
+ /** Page size. Defaults to `100`. */
72
+ limit?: number;
73
+ /** Field to sort by. */
74
+ sortBy?: string;
75
+ /** Sort direction, when `sortBy` is set. */
76
+ ascending?: boolean;
77
+ /** How many levels of related records to expand into each result. */
78
+ expansionLevel?: number;
79
+ } | {
80
+ /** Delete one record — the curated `DeleteEntityRecordCurated`. */
81
+ op: 'delete';
82
+ /** The record's `Id`. */
83
+ recordId: string;
84
+ } | {
85
+ /** Download the file stored in a record's file field — `GET /Attachment/{entityName}/{recordId}/{fieldName}`. */
86
+ op: 'downloadFile';
87
+ /** The record's `Id`. */
88
+ recordId: string;
89
+ /** The file field's name. */
90
+ field: string;
91
+ } | {
92
+ /** Upload a file into a record's file field — `POST /Attachment/{entityName}/{recordId}/{fieldName}`. */
93
+ op: 'uploadFile';
94
+ /** The record's `Id`. */
95
+ recordId: string;
96
+ /** The file field's name. */
97
+ field: string;
98
+ /** The file to upload — an `=`-expression reading a `file` variable, e.g. `'=vars.downloaded'`. */
99
+ file: string;
100
+ } | {
101
+ /** Delete the file stored in a record's file field — `DELETE /Attachment/{entityName}/{recordId}/{fieldName}`. */
102
+ op: 'deleteFile';
103
+ /** The record's `Id`. */
104
+ recordId: string;
105
+ /** The file field's name. */
106
+ field: string;
107
+ };
108
+ /** What the wire needs for one operation, before bindings and outputs are added. */
109
+ export interface DataServiceWire {
110
+ /** A display name when the author gave none, e.g. `Create ContractRegistry`. */
111
+ label: string;
112
+ /** The `uipath:context` rows that identify the operation — `operation`, `objectName`, `method`, `path`. */
113
+ context: Record<string, string>;
114
+ /** The payload rows, one per parameter plus the body. */
115
+ inputRows: TypedInputRow[];
116
+ }
117
+ /**
118
+ * The context and payload rows for one operation on one entity.
119
+ *
120
+ * @param entity - The Data Service entity, e.g. `'ContractRegistry'`.
121
+ * @param operation - Which operation, and its parameters.
122
+ * @returns The wire shape, without the connection/folder wiring the builder adds.
123
+ */
124
+ export declare function dataServiceWire(entity: string, operation: DataServiceOperation): DataServiceWire;
@@ -0,0 +1,98 @@
1
+ /** The connector key the tenant lists Data Fabric under. */
2
+ export const DATA_SERVICE_CONNECTOR_KEY = 'uipath-uipath-dataservice';
3
+ /** The extension type every Integration Service activity dispatches through. */
4
+ export const DATA_SERVICE_ACTIVITY_TYPE = 'Intsvc.ActivityExecution';
5
+ /**
6
+ * The context and payload rows for one operation on one entity.
7
+ *
8
+ * @param entity - The Data Service entity, e.g. `'ContractRegistry'`.
9
+ * @param operation - Which operation, and its parameters.
10
+ * @returns The wire shape, without the connection/folder wiring the builder adds.
11
+ */
12
+ export function dataServiceWire(entity, operation) {
13
+ const attachment = '/Attachment/{entityName}/{recordId}/{fieldName}';
14
+ switch (operation.op) {
15
+ case 'create':
16
+ return {
17
+ label: `Create ${entity}`,
18
+ context: { operation: 'Create', objectName: entity, method: 'POST', path: `/${entity}` },
19
+ inputRows: [bodyRow(operation.record)],
20
+ };
21
+ case 'update':
22
+ return {
23
+ label: `Update ${entity}`,
24
+ context: { operation: 'Replace', objectName: entity, method: 'PUT', path: `/${entity}/{id}` },
25
+ inputRows: [scalarRow('id', 'path', operation.id), bodyRow(operation.record)],
26
+ };
27
+ case 'get':
28
+ return {
29
+ label: `Get ${entity} record`,
30
+ context: { operation: 'List', objectName: 'GetEntityRecordByIdCurated', method: 'GET', path: '/v2/{entityName}/GetEntityRecord' },
31
+ inputRows: [
32
+ scalarRow('entityName', 'path', entity),
33
+ scalarRow('recordId', 'query', operation.recordId),
34
+ ...optionalRow('expansionLevel', 'query', operation.expansionLevel),
35
+ ],
36
+ };
37
+ case 'query':
38
+ return {
39
+ label: `Query ${entity}`,
40
+ context: { operation: 'Create', objectName: 'QueryEntityRecordsCurated', method: 'POST', path: '/v2/{entityName}/qer' },
41
+ inputRows: [
42
+ scalarRow('entityName', 'path', entity),
43
+ ...optionalRow('queryExpression', 'query', operation.where),
44
+ scalarRow('start', 'query', operation.start ?? 0),
45
+ scalarRow('limit', 'query', operation.limit ?? 100),
46
+ ...optionalRow('sortBy', 'query', operation.sortBy),
47
+ ...optionalRow('isAscending', 'query', operation.ascending),
48
+ ...optionalRow('expansionLevel', 'query', operation.expansionLevel),
49
+ ],
50
+ };
51
+ case 'delete':
52
+ return {
53
+ label: `Delete ${entity} record`,
54
+ context: { operation: 'Create', objectName: 'DeleteEntityRecordCurated', method: 'POST', path: '/v2/{entityName}/DeleteEntityRecord' },
55
+ inputRows: [scalarRow('entityName', 'path', entity), scalarRow('recordId', 'query', operation.recordId)],
56
+ };
57
+ case 'downloadFile':
58
+ return {
59
+ label: `Download ${entity}.${operation.field}`,
60
+ context: { operation: 'List', objectName: 'DownloadFileFromRecordField', method: 'GET', path: attachment },
61
+ inputRows: fileFieldRows(entity, operation.recordId, operation.field),
62
+ };
63
+ case 'uploadFile':
64
+ return {
65
+ label: `Upload ${entity}.${operation.field}`,
66
+ context: { operation: 'Create', objectName: 'UploadFileToRecordField', method: 'POST', path: attachment },
67
+ inputRows: [
68
+ ...fileFieldRows(entity, operation.recordId, operation.field),
69
+ { name: 'file', type: 'file', target: 'file', value: operation.file },
70
+ ],
71
+ };
72
+ case 'deleteFile':
73
+ return {
74
+ label: `Delete ${entity}.${operation.field}`,
75
+ context: { operation: 'Delete', objectName: 'DeleteFileFromRecordField', method: 'DELETE', path: attachment },
76
+ inputRows: fileFieldRows(entity, operation.recordId, operation.field),
77
+ };
78
+ }
79
+ }
80
+ function fileFieldRows(entity, recordId, field) {
81
+ return [scalarRow('entityName', 'path', entity), scalarRow('recordId', 'path', recordId), scalarRow('fieldName', 'path', field)];
82
+ }
83
+ /** The record as the one json `body` row. */
84
+ function bodyRow(record) {
85
+ return { name: 'body', type: 'json', target: 'body', body: record };
86
+ }
87
+ /**
88
+ * One parameter row, typed the way the converter types it: `integer` for a whole
89
+ * number, `number` otherwise, `boolean` for a boolean, `string` for a string — an
90
+ * `=`-expression included, whatever it evaluates to.
91
+ */
92
+ function scalarRow(name, target, value) {
93
+ const type = typeof value === 'number' ? (Number.isInteger(value) ? 'integer' : 'number') : typeof value === 'boolean' ? 'boolean' : 'string';
94
+ return { name, type, target, value: String(value) };
95
+ }
96
+ function optionalRow(name, target, value) {
97
+ return value === undefined ? [] : [scalarRow(name, target, value)];
98
+ }
@@ -3,7 +3,7 @@
3
3
  * decompile-cli — `<Name>.bpmn` → `<Name>.bpmn.ts`
4
4
  *
5
5
  * Usage:
6
- * node --experimental-strip-types decompile-cli.js <Name.bpmn> [-o Name.bpmn.ts] [--import <specifier>]
6
+ * node --experimental-strip-types decompile-cli.js <Name.bpmn> [-o Name.bpmn.ts] [--import <specifier>] [--style explicit|nested] [--keep-flow-ids]
7
7
  *
8
8
  * The reverse of compile-cli: reads a `.bpmn` and emits builder source whose
9
9
  * default export re-serializes to the same XML. `--import` sets the module
@@ -15,13 +15,21 @@
15
15
  * Diagram layout is not read or emitted — the SDK emits semantic-only XML and
16
16
  * `tidy` regenerates `bpmndi`. Run `tidy` on the recompiled artifact when a canvas
17
17
  * needs one.
18
+ *
19
+ * `--style nested` lifts the regions the builder's nesting constructs lower to —
20
+ * see `BpmnDecompileOptions.style`. It is written only after both sources have been
21
+ * built and found to describe the same graph (lift-verify.ts); otherwise the flat
22
+ * source is written and the reason printed. Best effort, never silent.
18
23
  */
19
24
  import { reportResult } from '../cli-result.js';
25
+ import { typeStripDecision } from '../node-runtime.js';
20
26
  import { readFileSync, writeFileSync } from 'node:fs';
21
27
  import { basename, resolve } from 'node:path';
22
28
  import { runWhenInvokedDirectly } from '../cli-run.js';
23
29
  import { decompile } from './decompile.js';
30
+ import { sameGraph } from './lift-verify.js';
24
31
  import { BPMN_IMPORT } from '../package-name.js';
32
+ import { fileURLToPath } from 'node:url';
25
33
  function opt(argv, name) {
26
34
  const i = argv.indexOf(name);
27
35
  return i >= 0 ? argv[i + 1] : undefined;
@@ -33,24 +41,78 @@ function opt(argv, name) {
33
41
  */
34
42
  export async function run(argv) {
35
43
  if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
36
- console.error('usage: decompile <Name.bpmn> [-o Name.bpmn.ts] [--import <specifier>]');
44
+ console.error('usage: decompile <Name.bpmn> [-o Name.bpmn.ts] [--import <specifier>] [--style explicit|nested] [--keep-flow-ids]');
45
+ return 2;
46
+ }
47
+ const style = opt(argv, '--style') ?? 'explicit';
48
+ if (style !== 'explicit' && style !== 'nested') {
49
+ console.error(`decompile: --style must be explicit or nested, not "${style}"`);
37
50
  return 2;
38
51
  }
39
- const flags = new Set(['-o', '--import']);
40
- const input = argv.find((a, i) => !flags.has(a) && !flags.has(argv[i - 1]));
52
+ // `--style nested` verifies the lift by IMPORTING the source it generated, so it
53
+ // needs Node's type stripping. Without it every verify failed with
54
+ // `Unknown file extension ".ts"`, which `sameGraph` reports as "the flat source did
55
+ // not build" — so the CLI wrote the flat form, blamed the lift, and exited 0. The
56
+ // feature was a silent no-op on Node 22.6–22.17 and anything older. Say which it is.
57
+ // Gated on `nested`: a plain decompile builds nothing and works everywhere.
58
+ if (style === 'nested') {
59
+ const decision = typeStripDecision(process.versions.node, process.execArgv);
60
+ if (decision !== 'native') {
61
+ console.error(`decompile: --style nested verifies the lift by importing TypeScript, which needs type stripping. ` +
62
+ (decision === 'flag'
63
+ ? `Node ${process.versions.node} has it behind a flag — re-run with \`node --experimental-strip-types\`, or use Node >= 22.18.`
64
+ : `Node ${process.versions.node} does not support it — use Node >= 22.18, or >= 22.6 with --experimental-strip-types.`));
65
+ return 2;
66
+ }
67
+ }
68
+ const keepFlowIds = argv.includes('--keep-flow-ids');
69
+ const flags = new Set(['-o', '--import', '--style']);
70
+ const input = argv.find((a, i) => a !== '--keep-flow-ids' && !flags.has(a) && !flags.has(argv[i - 1]));
41
71
  if (!input) {
42
72
  console.error('decompile: missing <Name.bpmn>');
43
73
  return 2;
44
74
  }
45
75
  const xml = readFileSync(input, 'utf8');
46
- const source = await decompile(xml, {
47
- importSpecifier: opt(argv, '--import') ?? BPMN_IMPORT,
48
- });
76
+ const importSpecifier = opt(argv, '--import') ?? BPMN_IMPORT;
77
+ let source = await decompile(xml, { importSpecifier });
78
+ let lifted;
79
+ let fallback;
80
+ if (style === 'nested') {
81
+ // Two rungs: the top level in sequence mode, then without it. Built against THIS
82
+ // package's own entry, whatever specifier the written file carries — the check is
83
+ // about the graph, not about module resolution. The flow ids the lift renamed are
84
+ // the one sanctioned difference.
85
+ const sdkEntry = fileURLToPath(new URL('./bpmn-sdk.js', import.meta.url));
86
+ for (const sequenceMode of [true, false]) {
87
+ let summary;
88
+ const nested = await decompile(xml, { importSpecifier, style: 'nested', relabelFlows: !keepFlowIds, sequenceMode, onLift: (s) => (summary = s) });
89
+ const renamed = new Map((summary?.renamedFlows ?? []).map((r) => [r.from, r.to]));
90
+ const verdict = await sameGraph(source, nested, sdkEntry, renamed);
91
+ if (verdict.same) {
92
+ source = nested;
93
+ lifted = summary;
94
+ fallback = undefined;
95
+ break;
96
+ }
97
+ fallback = verdict.reason ?? 'the graphs differ';
98
+ }
99
+ }
49
100
  const base = basename(input).replace(/\.bpmn$/, '');
50
101
  const out = opt(argv, '-o') ?? `${base}.bpmn.ts`;
51
102
  writeFileSync(out, source);
52
103
  console.log(`decompile: wrote ${out}`);
53
- reportResult({ path: resolve(out) });
104
+ if (lifted) {
105
+ const flat = lifted.leftFlat.length ? `; left flat: ${lifted.leftFlat.map((r) => `${r.id} (${r.reason})`).join('; ')}` : '';
106
+ const renamed = lifted.renamedFlows.length
107
+ ? `; renamed ${lifted.renamedFlows.length} flow id(s) to Flow_<source>_<target> — pass no --keep-flow-ids and the ids are pinned instead`
108
+ : '';
109
+ const mode = lifted.sequence ? ', top level in sequence mode' : ', top level with explicit flows';
110
+ console.log(`decompile: nested — lifted ${lifted.handlers} handler(s), ${lifted.branches} branch(es), ${lifted.nets} event sub-process(es)${mode}${renamed}${flat}`);
111
+ }
112
+ else if (fallback) {
113
+ console.log(`decompile: --style nested could not be verified, wrote the flat form — ${fallback}`);
114
+ }
115
+ reportResult({ path: resolve(out), style: lifted ? 'nested' : 'explicit', ...(lifted ? { lifted } : {}), ...(fallback ? { fallback } : {}) });
54
116
  return 0;
55
117
  }
56
118
  runWhenInvokedDirectly(import.meta.url, 'decompile', run);
@@ -2,5 +2,76 @@
2
2
  export interface BpmnDecompileOptions {
3
3
  /** Module specifier for the emitted `import { bpmn }` line. */
4
4
  importSpecifier?: string;
5
+ /**
6
+ * `'explicit'` (the default) renders the graph flat: one call per element, one
7
+ * `.sequenceFlow()` per edge. `'nested'` LIFTS the regions that the builder's
8
+ * nesting constructs lower to — a boundary event into its activity's body, a
9
+ * gateway and its arms into `.choose()` / `.fork()` / `.race()`, an event
10
+ * sub-process into `.eventSubProcess()` — and leaves every other region flat.
11
+ *
12
+ * Best effort by construction, and safe only with the check `decompile-cli`
13
+ * runs: the lifted source must build the same graph as the flat one — the flow
14
+ * ids the lift renamed excepted, see {@link BpmnDecompileOptions.relabelFlows}.
15
+ * See docs/BPMN_AUTHORING_ERGONOMICS_DESIGN.md §9.
16
+ */
17
+ style?: 'explicit' | 'nested';
18
+ /**
19
+ * With `style: 'nested'`: lift a region whose flow ids are NOT the ones the
20
+ * constructs derive. Defaults to `true`, because a construct derives its flow ids
21
+ * and nothing an import carries (`edge_zOoHdO`, `Flow_0abc12`) ever matches — with
22
+ * such regions skipped the lift proved nothing on any real export, 0 of 21 regions
23
+ * on the corpus file it was measured against.
24
+ *
25
+ * The ids are KEPT, not rewritten. The lift emits `.flowIds([…])` naming every
26
+ * implied edge whose id is not the derived one, so the recompile reproduces the ids
27
+ * the artifact arrived with. That matters because `uip maestro bpmn merge` keys
28
+ * every element by id, flows included: a renamed flow reads as a new one, the
29
+ * original is deleted and takes its diagram edge with it, and a gateway `default`
30
+ * preserved from the original is left naming an id that no longer exists — a file
31
+ * that does not validate. Measured on a three-flow fixture, a no-edit round trip
32
+ * through the lift now merges back byte-identical.
33
+ *
34
+ * Set `false` (`--keep-flow-ids`) to confine the lift to regions that already carry
35
+ * the derived ids, which produces source with no pin table at the cost of lifting
36
+ * far less. {@link LiftSummary.renamedFlows} stays empty unless that is set.
37
+ */
38
+ relabelFlows?: boolean;
39
+ /**
40
+ * With `style: 'nested'`: write the top level in `.flowMode('sequence')`, so the edge
41
+ * between consecutive statements is implied and only a merge, a back edge or a
42
+ * fan-out is a `.sequenceFlow()`. Defaults to `true`. The lift emits statements in
43
+ * control-flow order and writes an explicit edge in place wherever the chain breaks,
44
+ * so the builder's "explicit wins" rule keeps every implied edge honest; a
45
+ * sub-process body it renders flat pins `.flowMode('explicit')` for itself. When the
46
+ * result does not build the same graph, `decompile-cli` retries without it.
47
+ */
48
+ sequenceMode?: boolean;
49
+ /** Receives what the nested style lifted and what it left flat, and why. */
50
+ onLift?: (summary: LiftSummary) => void;
51
+ }
52
+ /** What `style: 'nested'` did — see {@link BpmnDecompileOptions.style}. */
53
+ export interface LiftSummary {
54
+ /** Boundary events written into their activity's body. */
55
+ handlers: number;
56
+ /** Gateways written as `choose` / `fork` / `race`. */
57
+ branches: number;
58
+ /** Event sub-processes written as `eventSubProcess`. */
59
+ nets: number;
60
+ /** Regions considered and left flat, each with the reason. */
61
+ leftFlat: Array<{
62
+ id: string;
63
+ reason: string;
64
+ }>;
65
+ /**
66
+ * Flows a lifted construct now implies under a different id — the import's id and
67
+ * the `Flow_<source>_<target>` the recompile writes. Empty when
68
+ * {@link BpmnDecompileOptions.relabelFlows} is off.
69
+ */
70
+ renamedFlows: Array<{
71
+ from: string;
72
+ to: string;
73
+ }>;
74
+ /** Whether the top level was written in `.flowMode('sequence')`. */
75
+ sequence: boolean;
5
76
  }
6
77
  export declare function decompile(xml: string, options?: BpmnDecompileOptions): Promise<string>;