@uipath/maestro-builder-sdk 6.19.0 → 6.20.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.
- package/dist/api-index.md +107 -107
- package/dist/api-members.md +358 -356
- package/dist/check.js +181 -45
- package/dist/cli/commands/flow.js +3 -0
- package/dist/compile-cli.js +51 -0
- package/dist/core/actions.d.ts +13 -5
- package/dist/core/actions.js +1 -1
- package/dist/core/binding-identity.d.ts +22 -0
- package/dist/core/binding-identity.js +28 -0
- package/dist/core/context-index.d.ts +34 -0
- package/dist/core/context-index.js +38 -0
- package/dist/core/definitionless-node-types.d.ts +16 -0
- package/dist/core/definitionless-node-types.js +16 -0
- package/dist/core/form-field-type.d.ts +33 -0
- package/dist/core/form-field-type.js +30 -0
- package/dist/core/hitl-routing.d.ts +8 -0
- package/dist/core/hitl-routing.js +17 -1
- package/dist/core/process-resource-sort-order.d.ts +40 -0
- package/dist/core/process-resource-sort-order.js +44 -0
- package/dist/core/wire-expr.d.ts +27 -0
- package/dist/core/wire-expr.js +34 -0
- package/dist/decompile-cli.js +11 -1
- package/dist/decompile.d.ts +8 -3
- package/dist/decompile.js +680 -104
- package/dist/flow-expr-check.js +1 -1
- package/dist/flow-sdk.d.ts +33 -1
- package/dist/flow-sdk.js +21 -2
- package/dist/flow-step-tree.js +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/merge-cli.js +25 -8
- package/dist/merge-roundtrip.d.ts +81 -0
- package/dist/merge-roundtrip.js +404 -0
- package/dist/merge.d.ts +35 -6
- package/dist/merge.js +124 -24
- package/dist/node-placement.d.ts +93 -0
- package/dist/node-placement.js +163 -0
- package/dist/nodes/transform/flow/node-type.js +2 -1
- package/dist/registry/uip/pipeline-front-door.js +5 -3
- package/dist/serialize.js +112 -61
- package/package.json +2 -2
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* merge-roundtrip — what the no-edit round trip changed that the merged file
|
|
3
|
+
* still carries, and what the merged file gets wrong from mixing its sources.
|
|
4
|
+
*
|
|
5
|
+
* `merge` is given `baseline = compile(decompile(original))`, the round trip of
|
|
6
|
+
* the original with NO edit. Wherever baseline and original differ in wire
|
|
7
|
+
* identity, decompile or compile lost or invented something. The merged file
|
|
8
|
+
* inherits that change unless the author's edit undid it, so each difference is
|
|
9
|
+
* reported only when the merged file still differs from the original at that
|
|
10
|
+
* spot. For example:
|
|
11
|
+
*
|
|
12
|
+
* original: logFailure1@success -> forEachItem1@continue
|
|
13
|
+
* baseline: logFailure1@success -> callApi1ErrorEnd@input (validate: Success)
|
|
14
|
+
*
|
|
15
|
+
* is reported when the merged file has the `callApi1ErrorEnd` edge, and not
|
|
16
|
+
* when the author re-wired `logFailure1@success` back to `forEachItem1@continue`
|
|
17
|
+
* in the edited source.
|
|
18
|
+
*
|
|
19
|
+
* BPMN has the same invariant as a gate: `bpmn/lift-verify.ts` builds both
|
|
20
|
+
* sources and writes the nested one only when the graphs agree. Here the two
|
|
21
|
+
* graphs are already built, so this compares them directly:
|
|
22
|
+
*
|
|
23
|
+
* - node ids (per scope: the root and each subflow), and the type of a node the
|
|
24
|
+
* merge takes from the edited side;
|
|
25
|
+
* - edges as `(source, sourcePort, target, targetPort)`; edge ids are not
|
|
26
|
+
* identity;
|
|
27
|
+
* - switch case ids and human-task outcome ids, which name the handles the
|
|
28
|
+
* edges leave from;
|
|
29
|
+
* - binding rows by what they bind (`core/binding-identity.ts`), never by the
|
|
30
|
+
* row `id`.
|
|
31
|
+
*
|
|
32
|
+
* Two checks run on the MERGED file whatever the baseline says, because merge
|
|
33
|
+
* mixes sources (untouched nodes from the original, edges from the edited file):
|
|
34
|
+
*
|
|
35
|
+
* - every root edge leaves from a handle its source node declares — the static
|
|
36
|
+
* handles of the node's definition, its `repeat` handles expanded over the
|
|
37
|
+
* node's inputs (`case-{item.id}` over `inputs.cases`), and for a human task
|
|
38
|
+
* the `outcome-<id>` handles the product derives from its outcomes. This is
|
|
39
|
+
* the rule `flow validate` applies ("Edge references undeclared source
|
|
40
|
+
* handle");
|
|
41
|
+
* - no two binding rows bind the same resource property.
|
|
42
|
+
*
|
|
43
|
+
* Two classes are reported but never refused, because the merged file does the
|
|
44
|
+
* same thing as the original:
|
|
45
|
+
*
|
|
46
|
+
* - an End the compile appends to a tail the original left open. The canvas
|
|
47
|
+
* does not require a trailing End, the SDK always writes one, and a path that
|
|
48
|
+
* runs out of nodes ends either way;
|
|
49
|
+
* - a binding row the round trip drops. Merge keeps every original row, so the
|
|
50
|
+
* merged file still has it.
|
|
51
|
+
*/
|
|
52
|
+
import { bindingIdentity } from './core/binding-identity.js';
|
|
53
|
+
import { OUTCOME_HANDLE_NODE_TYPES } from './core/hitl-routing.js';
|
|
54
|
+
/** A file's definitions, keyed `nodeType@version`. */
|
|
55
|
+
function defsOf(f) {
|
|
56
|
+
const out = new Map();
|
|
57
|
+
for (const d of f.definitions ?? [])
|
|
58
|
+
out.set(`${String(d.nodeType)}@${String(d.version)}`, d);
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
const defOf = (defs, n) => defs.get(`${n.type}@${String(n.typeVersion)}`);
|
|
62
|
+
/** The root and every subflow, keyed `''` for the root and `subflows.<key>` otherwise. */
|
|
63
|
+
function scopesOf(f) {
|
|
64
|
+
const out = new Map([['', { nodes: f.nodes ?? [], edges: f.edges ?? [] }]]);
|
|
65
|
+
for (const [key, sf] of Object.entries(f.subflows ?? {})) {
|
|
66
|
+
const s = (sf ?? {});
|
|
67
|
+
out.set(`subflows.${key}`, { nodes: s.nodes ?? [], edges: s.edges ?? [] });
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
const where = (scope) => (scope ? ` in ${scope}` : '');
|
|
72
|
+
const edgeText = (e) => `${e.sourceNodeId}@${e.sourcePort} -> ${e.targetNodeId}@${e.targetPort}`;
|
|
73
|
+
const edgeKey = (e) => JSON.stringify([e.sourceNodeId, e.sourcePort, e.targetNodeId, e.targetPort]);
|
|
74
|
+
/** `inputs.schema` may be stored as a JSON string. */
|
|
75
|
+
function schemaOf(n) {
|
|
76
|
+
let schema = n.inputs?.schema;
|
|
77
|
+
if (typeof schema === 'string') {
|
|
78
|
+
try {
|
|
79
|
+
schema = JSON.parse(schema);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return schema && typeof schema === 'object' ? schema : undefined;
|
|
86
|
+
}
|
|
87
|
+
function idsOf(list) {
|
|
88
|
+
return Array.isArray(list) ? list.map((x) => x?.id).filter((id) => typeof id === 'string') : [];
|
|
89
|
+
}
|
|
90
|
+
function bindingText(b) {
|
|
91
|
+
const sub = b.resourceSubType ? `/${String(b.resourceSubType)}` : '';
|
|
92
|
+
return `${String(b.resource)}${sub} "${String(b.resourceKey ?? '')}" ${String(b.propertyAttribute ?? b.name)}`;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Every wire-identity difference between the original and its no-edit round
|
|
96
|
+
* trip that the merged file still carries, phrased as what the merge writes.
|
|
97
|
+
*
|
|
98
|
+
* A difference the author's edit undid is not reported: an edge the round trip
|
|
99
|
+
* dropped that the merged file has again, an edge or node the round trip added
|
|
100
|
+
* that the merged file lacks, a node whose type or handles the round trip
|
|
101
|
+
* changed that the merged file no longer has (or has with the original's).
|
|
102
|
+
*
|
|
103
|
+
* @param original - The file being merged into.
|
|
104
|
+
* @param baseline - compile(decompile(original)) before any edit.
|
|
105
|
+
* @param merged - The merged file, before it is written.
|
|
106
|
+
* @returns One finding per difference, in a stable order.
|
|
107
|
+
*/
|
|
108
|
+
export function roundTripFindings(original, baseline, merged) {
|
|
109
|
+
const out = [];
|
|
110
|
+
const origDefs = defsOf(original);
|
|
111
|
+
const baseDefs = defsOf(baseline);
|
|
112
|
+
const mergedDefs = defsOf(merged);
|
|
113
|
+
const origScopes = scopesOf(original);
|
|
114
|
+
const baseScopes = scopesOf(baseline);
|
|
115
|
+
const mergedScopes = scopesOf(merged);
|
|
116
|
+
const scopeKeys = [...new Set([...origScopes.keys(), ...baseScopes.keys()])];
|
|
117
|
+
for (const scope of scopeKeys) {
|
|
118
|
+
const o = origScopes.get(scope) ?? { nodes: [], edges: [] };
|
|
119
|
+
const b = baseScopes.get(scope) ?? { nodes: [], edges: [] };
|
|
120
|
+
const m = mergedScopes.get(scope);
|
|
121
|
+
if (!origScopes.has(scope)) {
|
|
122
|
+
if (m)
|
|
123
|
+
out.push({ kind: 'node-added', refuses: true, message: `the round trip adds ${scope}, which the original does not have; merge will write it.` });
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (!baseScopes.has(scope)) {
|
|
127
|
+
if (!m)
|
|
128
|
+
out.push({ kind: 'node-removed', refuses: true, message: `the round trip drops ${scope}; merge will not write it.` });
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const oNodes = new Map(o.nodes.map((n) => [n.id, n]));
|
|
132
|
+
const bNodes = new Map(b.nodes.map((n) => [n.id, n]));
|
|
133
|
+
const mNodes = new Map((m?.nodes ?? []).map((n) => [n.id, n]));
|
|
134
|
+
const mKeys = new Set((m?.edges ?? []).map(edgeKey));
|
|
135
|
+
const inMerged = (e) => mKeys.has(edgeKey(e));
|
|
136
|
+
// ── Edges, first, because whether an added End is neutral depends on them. ──
|
|
137
|
+
const oKeys = new Set(o.edges.map(edgeKey));
|
|
138
|
+
const bKeys = new Set(b.edges.map(edgeKey));
|
|
139
|
+
let removed = o.edges.filter((e) => !bKeys.has(edgeKey(e)));
|
|
140
|
+
let added = b.edges.filter((e) => !oKeys.has(edgeKey(e)));
|
|
141
|
+
// Same source, same target, different handle(s): the handle was renamed
|
|
142
|
+
// (`case-default-1` -> `case-case_1`, `completed` -> one edge per outcome).
|
|
143
|
+
// Reported while the merged file carries a renamed edge.
|
|
144
|
+
const byLink = (e) => JSON.stringify([e.sourceNodeId, e.targetNodeId, e.targetPort]);
|
|
145
|
+
for (const link of [...new Set(removed.map(byLink))]) {
|
|
146
|
+
const r = removed.filter((e) => byLink(e) === link);
|
|
147
|
+
const a = added.filter((e) => byLink(e) === link);
|
|
148
|
+
if (a.length === 0)
|
|
149
|
+
continue;
|
|
150
|
+
removed = removed.filter((e) => byLink(e) !== link);
|
|
151
|
+
added = added.filter((e) => byLink(e) !== link);
|
|
152
|
+
const live = a.filter(inMerged);
|
|
153
|
+
if (live.length === 0)
|
|
154
|
+
continue;
|
|
155
|
+
const [s, t] = [r[0].sourceNodeId, `${r[0].targetNodeId}@${r[0].targetPort}`];
|
|
156
|
+
out.push({
|
|
157
|
+
kind: 'edge-handle-renamed', refuses: true,
|
|
158
|
+
message: `the round trip moves the edge ${s} -> ${t}${where(scope)} from handle `
|
|
159
|
+
+ `${r.map((e) => `"${e.sourcePort}"`).join(', ')} to ${live.map((e) => `"${e.sourcePort}"`).join(', ')}; merge will write the new handle(s).`,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
// Same source handle, different target: the edge was rewired. Reported
|
|
163
|
+
// while the merged file carries a rewired edge.
|
|
164
|
+
const byPort = (e) => JSON.stringify([e.sourceNodeId, e.sourcePort]);
|
|
165
|
+
for (const port of [...new Set(removed.map(byPort))]) {
|
|
166
|
+
const r = removed.filter((e) => byPort(e) === port);
|
|
167
|
+
const a = added.filter((e) => byPort(e) === port);
|
|
168
|
+
if (a.length === 0)
|
|
169
|
+
continue;
|
|
170
|
+
removed = removed.filter((e) => byPort(e) !== port);
|
|
171
|
+
added = added.filter((e) => byPort(e) !== port);
|
|
172
|
+
const live = a.filter(inMerged);
|
|
173
|
+
if (live.length === 0)
|
|
174
|
+
continue;
|
|
175
|
+
out.push({
|
|
176
|
+
kind: 'edge-rewired', refuses: true,
|
|
177
|
+
message: `the round trip rewires ${r[0].sourceNodeId}@${r[0].sourcePort}${where(scope)} from `
|
|
178
|
+
+ `${r.map((e) => `${e.targetNodeId}@${e.targetPort}`).join(', ')} to ${live.map((e) => `${e.targetNodeId}@${e.targetPort}`).join(', ')}; merge will write the new target.`,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
// A dropped edge is reported while both its ends are in the merged file and
|
|
182
|
+
// the edge is not: an edge into a node the author deleted is gone by the edit.
|
|
183
|
+
for (const e of removed) {
|
|
184
|
+
if (inMerged(e) || !mNodes.has(e.sourceNodeId) || !mNodes.has(e.targetNodeId))
|
|
185
|
+
continue;
|
|
186
|
+
out.push({ kind: 'edge-removed', refuses: true, message: `the round trip drops the edge ${edgeText(e)}${where(scope)}; merge will not write it.` });
|
|
187
|
+
}
|
|
188
|
+
// An added TERMINAL node (its definition declares no source handle — an
|
|
189
|
+
// End) is neutral when every edge into it leaves a handle the original left
|
|
190
|
+
// open (no edge from that source handle at all). Read off the manifest, not
|
|
191
|
+
// a type name, so any terminal type counts.
|
|
192
|
+
const openInOriginal = (e) => !o.edges.some((x) => x.sourceNodeId === e.sourceNodeId && x.sourcePort === e.sourcePort);
|
|
193
|
+
const neutralEnd = new Set();
|
|
194
|
+
for (const n of b.nodes) {
|
|
195
|
+
if (oNodes.has(n.id) || declaredSourceHandles(n, defOf(baseDefs, n))?.size !== 0)
|
|
196
|
+
continue;
|
|
197
|
+
const into = b.edges.filter((e) => e.targetNodeId === n.id);
|
|
198
|
+
if (into.length > 0 && into.every((e) => added.includes(e) && openInOriginal(e)))
|
|
199
|
+
neutralEnd.add(n.id);
|
|
200
|
+
}
|
|
201
|
+
for (const e of added) {
|
|
202
|
+
if (neutralEnd.has(e.targetNodeId) || !inMerged(e))
|
|
203
|
+
continue;
|
|
204
|
+
out.push({ kind: 'edge-added', refuses: true, message: `the round trip adds the edge ${edgeText(e)}${where(scope)}; merge will write it.` });
|
|
205
|
+
}
|
|
206
|
+
// ── Nodes ──
|
|
207
|
+
for (const n of o.nodes) {
|
|
208
|
+
if (!bNodes.has(n.id) && !mNodes.has(n.id)) {
|
|
209
|
+
out.push({ kind: 'node-removed', refuses: true, message: `the round trip drops node "${n.id}" (${n.type})${where(scope)}; merge will not write it.` });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
for (const n of b.nodes) {
|
|
213
|
+
if (oNodes.has(n.id) || !mNodes.has(n.id))
|
|
214
|
+
continue;
|
|
215
|
+
if (neutralEnd.has(n.id)) {
|
|
216
|
+
const tails = b.edges.filter((e) => e.targetNodeId === n.id).map((e) => `${e.sourceNodeId}@${e.sourcePort}`);
|
|
217
|
+
out.push({
|
|
218
|
+
kind: 'implicit-end', refuses: false,
|
|
219
|
+
message: `the round trip appends End "${n.id}" (${n.type})${where(scope)} after ${tails.join(', ')}, which the original leaves open; `
|
|
220
|
+
+ `merge will write it (same behaviour: a path that runs out of nodes ends; not refused under --strict).`,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
out.push({ kind: 'node-added', refuses: true, message: `the round trip adds node "${n.id}" (${n.type})${where(scope)}; merge will write it.` });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
for (const n of o.nodes) {
|
|
228
|
+
const bn = bNodes.get(n.id);
|
|
229
|
+
const mn = mNodes.get(n.id);
|
|
230
|
+
if (!bn || !mn)
|
|
231
|
+
continue;
|
|
232
|
+
if (bn.type !== n.type && mn.type === bn.type) {
|
|
233
|
+
out.push({
|
|
234
|
+
kind: 'node-type', refuses: true,
|
|
235
|
+
message: `the round trip changes node "${n.id}"${where(scope)} from ${n.type} to ${bn.type}; merge will write ${bn.type}.`,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
// Handle ids live on the node; the edges that leave them come from the
|
|
239
|
+
// edited side. A merged node with the round-trip ids is reported as is; a
|
|
240
|
+
// merged node with the original's ids (merge kept the original node) only
|
|
241
|
+
// when a merged edge leaves it on a round-trip id.
|
|
242
|
+
if (n.type !== bn.type || mn.type !== n.type)
|
|
243
|
+
continue;
|
|
244
|
+
const oh = derivedHandles(n, defOf(origDefs, n));
|
|
245
|
+
const bh = derivedHandles(bn, defOf(baseDefs, bn) ?? defOf(origDefs, n));
|
|
246
|
+
if (!oh || !bh || JSON.stringify(oh) === JSON.stringify(bh))
|
|
247
|
+
continue;
|
|
248
|
+
const mh = JSON.stringify(derivedHandles(mn, defOf(mergedDefs, mn) ?? defOf(origDefs, n)));
|
|
249
|
+
const roundTripOnly = new Set(bh.filter((h) => !oh.includes(h)));
|
|
250
|
+
let consequence;
|
|
251
|
+
if (mh === JSON.stringify(bh))
|
|
252
|
+
consequence = 'merge will write the round-trip ids';
|
|
253
|
+
else if (mh === JSON.stringify(oh) && (m?.edges ?? []).some((e) => e.sourceNodeId === n.id && roundTripOnly.has(e.sourcePort))) {
|
|
254
|
+
consequence = 'merge keeps the original node, but the edges it writes name the round-trip ids';
|
|
255
|
+
}
|
|
256
|
+
if (!consequence)
|
|
257
|
+
continue;
|
|
258
|
+
out.push({
|
|
259
|
+
kind: 'handle-ids', refuses: true,
|
|
260
|
+
message: `the round trip renames node "${n.id}" (${n.type})${where(scope)} handles ${oh.join(', ') || '(none)'} to ${bh.join(', ') || '(none)'}; ${consequence}.`,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// ── Bindings, by what they bind ──
|
|
265
|
+
const oRows = new Map((original.bindings ?? []).map((r) => [bindingIdentity(r), r]));
|
|
266
|
+
const bRows = new Map((baseline.bindings ?? []).map((r) => [bindingIdentity(r), r]));
|
|
267
|
+
const mRows = new Set((merged.bindings ?? []).map((r) => bindingIdentity(r)));
|
|
268
|
+
for (const [k, r] of oRows) {
|
|
269
|
+
if (!bRows.has(k)) {
|
|
270
|
+
out.push({ kind: 'binding-removed', refuses: false, message: `the round trip drops the binding row for ${bindingText(r)}; merge keeps the original's row (not refused under --strict).` });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
for (const [k, r] of bRows) {
|
|
274
|
+
if (!oRows.has(k) && mRows.has(k)) {
|
|
275
|
+
out.push({ kind: 'binding-added', refuses: true, message: `the round trip adds a binding row for ${bindingText(r)}; merge will write it.` });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
/** The source handles a node's definition declares, or `undefined` when they cannot be known. */
|
|
281
|
+
function declaredSourceHandles(node, def) {
|
|
282
|
+
const groups = def?.handleConfiguration;
|
|
283
|
+
if (!Array.isArray(groups))
|
|
284
|
+
return undefined;
|
|
285
|
+
const out = new Set();
|
|
286
|
+
for (const g of groups) {
|
|
287
|
+
for (const h of (g?.handles ?? [])) {
|
|
288
|
+
if (h?.type !== 'source' || typeof h.id !== 'string')
|
|
289
|
+
continue;
|
|
290
|
+
const template = /\{item\.([A-Za-z0-9_]+)\}/.exec(h.id);
|
|
291
|
+
if (!template) {
|
|
292
|
+
out.add(h.id);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (typeof h.repeat !== 'string' || !h.repeat.startsWith('inputs.'))
|
|
296
|
+
return undefined; // a template this check cannot expand
|
|
297
|
+
let items = node.inputs;
|
|
298
|
+
for (const seg of h.repeat.slice('inputs.'.length).split('.'))
|
|
299
|
+
items = items?.[seg];
|
|
300
|
+
for (const item of Array.isArray(items) ? items : []) {
|
|
301
|
+
const v = item?.[template[1]];
|
|
302
|
+
if (typeof v === 'string' || typeof v === 'number')
|
|
303
|
+
out.add(h.id.replace(template[0], String(v)));
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* The handles a node derives from its own inputs, in order: a definition's
|
|
311
|
+
* `repeat` handles expanded (`case-{item.id}` over `inputs.cases` gives
|
|
312
|
+
* `case-default-1`), and a human task's `outcome-<id>` per outcome. These are
|
|
313
|
+
* the handle ids a round trip can rename; static handles cannot change.
|
|
314
|
+
* `undefined` when the node has no definition and is not a human task.
|
|
315
|
+
*/
|
|
316
|
+
function derivedHandles(node, def) {
|
|
317
|
+
const out = [];
|
|
318
|
+
const groups = def?.handleConfiguration;
|
|
319
|
+
for (const g of Array.isArray(groups) ? groups : []) {
|
|
320
|
+
for (const h of (g?.handles ?? [])) {
|
|
321
|
+
if (h?.type !== 'source' || typeof h.id !== 'string' || !h.id.includes('{item.'))
|
|
322
|
+
continue;
|
|
323
|
+
const single = { ...def, handleConfiguration: [{ handles: [h] }] };
|
|
324
|
+
out.push(...(declaredSourceHandles(node, single) ?? []));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (OUTCOME_HANDLE_NODE_TYPES.has(node.type)) {
|
|
328
|
+
for (const id of idsOf(schemaOf(node)?.outcomes))
|
|
329
|
+
if (!out.includes(`outcome-${id}`))
|
|
330
|
+
out.push(`outcome-${id}`);
|
|
331
|
+
}
|
|
332
|
+
else if (!Array.isArray(groups)) {
|
|
333
|
+
return undefined;
|
|
334
|
+
}
|
|
335
|
+
return out;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Inconsistencies in the merged file that come from mixing its sources: a root
|
|
339
|
+
* edge leaving a handle its source node does not declare, and two binding rows
|
|
340
|
+
* binding the same resource property.
|
|
341
|
+
*
|
|
342
|
+
* A node with no definition in `merged.definitions` (a sticky note, a type
|
|
343
|
+
* neither input carried) is not judged. Neither is anything the original
|
|
344
|
+
* already had — an edge it carries verbatim, or as many same-identity binding
|
|
345
|
+
* rows as it already holds — because the merge did not cause it.
|
|
346
|
+
*
|
|
347
|
+
* @param merged - The merged file.
|
|
348
|
+
* @param original - The file merged into.
|
|
349
|
+
* @returns One finding per inconsistency.
|
|
350
|
+
*/
|
|
351
|
+
export function mergedFileFindings(merged, original = {}) {
|
|
352
|
+
const out = [];
|
|
353
|
+
const originalEdges = new Set((original.edges ?? []).map(edgeKey));
|
|
354
|
+
const originalRows = new Map();
|
|
355
|
+
for (const r of original.bindings ?? [])
|
|
356
|
+
originalRows.set(bindingIdentity(r), (originalRows.get(bindingIdentity(r)) ?? 0) + 1);
|
|
357
|
+
const nodes = new Map((merged.nodes ?? []).map((n) => [n.id, n]));
|
|
358
|
+
const defs = new Map();
|
|
359
|
+
for (const d of merged.definitions ?? [])
|
|
360
|
+
defs.set(`${String(d.nodeType)}@${String(d.version)}`, d);
|
|
361
|
+
for (const e of merged.edges ?? []) {
|
|
362
|
+
const n = nodes.get(e.sourceNodeId);
|
|
363
|
+
if (!n || originalEdges.has(edgeKey(e)))
|
|
364
|
+
continue;
|
|
365
|
+
let declared = declaredSourceHandles(n, defs.get(`${n.type}@${String(n.typeVersion)}`));
|
|
366
|
+
if (OUTCOME_HANDLE_NODE_TYPES.has(n.type)) {
|
|
367
|
+
// The product derives a human task's `outcome-<id>` handles from its
|
|
368
|
+
// outcomes; the manifest lists only `completed`. (A `completed` edge on a
|
|
369
|
+
// task WITH outcomes is a stale placeholder the product remaps, which
|
|
370
|
+
// `validate` accepts and canvas files carry — not judged here.)
|
|
371
|
+
declared = new Set([...(declared ?? ['completed']), ...idsOf(schemaOf(n)?.outcomes).map((id) => `outcome-${id}`)]);
|
|
372
|
+
}
|
|
373
|
+
if (!declared || declared.has(e.sourcePort))
|
|
374
|
+
continue;
|
|
375
|
+
out.push({
|
|
376
|
+
kind: 'undeclared-handle', refuses: true,
|
|
377
|
+
message: `the merged edge ${edgeText(e)} leaves handle "${e.sourcePort}", which node "${n.id}" (${n.type}) does not declare `
|
|
378
|
+
+ `(it declares ${[...declared].map((h) => `"${h}"`).join(', ') || 'none'}); \`validate\` will report "undeclared source handle".`,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
const seen = new Map();
|
|
382
|
+
for (const r of merged.bindings ?? []) {
|
|
383
|
+
const k = bindingIdentity(r);
|
|
384
|
+
seen.set(k, [...(seen.get(k) ?? []), String(r.id ?? '(no id)')]);
|
|
385
|
+
}
|
|
386
|
+
for (const [k, ids] of seen) {
|
|
387
|
+
if (ids.length < 2 || ids.length <= (originalRows.get(k) ?? 0))
|
|
388
|
+
continue;
|
|
389
|
+
const r = (merged.bindings ?? []).find((x) => bindingIdentity(x) === k);
|
|
390
|
+
out.push({ kind: 'duplicate-binding', refuses: true, message: `the merged file has ${ids.length} binding rows for ${bindingText(r)} (ids ${ids.join(', ')}).` });
|
|
391
|
+
}
|
|
392
|
+
return out;
|
|
393
|
+
}
|
|
394
|
+
/** Thrown by `mergeFlow` under `strict` when a finding refuses the merge. */
|
|
395
|
+
export class MergeRefusedError extends Error {
|
|
396
|
+
/** Every refusing finding, in report order. */
|
|
397
|
+
findings;
|
|
398
|
+
constructor(findings) {
|
|
399
|
+
super(`refused (--strict): the merged file would differ from the original where the author made no edit:\n`
|
|
400
|
+
+ findings.map((f) => ` - ${f.message}`).join('\n'));
|
|
401
|
+
this.name = 'MergeRefusedError';
|
|
402
|
+
this.findings = findings;
|
|
403
|
+
}
|
|
404
|
+
}
|
package/dist/merge.d.ts
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* preserved from the original; `subflows` and edges (with their ids) come from
|
|
29
29
|
* `edited`; `definitions` and `bindings` are unioned
|
|
30
30
|
* (original wins on conflict, since the original's are the tenant-resolved
|
|
31
|
-
* authority). Node ids are stable by construction (decompile names every step by
|
|
31
|
+
* authority). A binding row's identity is what it binds, not its row id. Node ids are stable by construction (decompile names every step by
|
|
32
32
|
* its node id), which is what lets every re-attach key on id.
|
|
33
33
|
*
|
|
34
34
|
* ## Definitions are keyed by `nodeType@version`, not by `nodeType`
|
|
@@ -63,7 +63,18 @@
|
|
|
63
63
|
* A node whose pair is in NEITHER file cannot be given a definition here, and a
|
|
64
64
|
* merge that stayed silent about it wrote a file it already knew would fail
|
|
65
65
|
* `validate`. Those are reported through {@link MergeOptions.warn}.
|
|
66
|
+
*
|
|
67
|
+
* ## The baseline is also compared with the original
|
|
68
|
+
*
|
|
69
|
+
* `baseline` is the no-edit round trip, so wherever it differs from the original
|
|
70
|
+
* in wire identity (a node, an edge, a switch case id, a human-task outcome id,
|
|
71
|
+
* a binding row), the merged file inherits that change unless the author's edit
|
|
72
|
+
* undid it. Each difference the merged file still carries is reported through
|
|
73
|
+
* `warn`, and `strict` refuses the merge over it; the merged file is also
|
|
74
|
+
* checked for an edge that leaves a handle its kept source node does not
|
|
75
|
+
* declare. See merge-roundtrip.ts.
|
|
66
76
|
*/
|
|
77
|
+
export { MergeRefusedError, type MergeFinding } from './merge-roundtrip.js';
|
|
67
78
|
interface AnyNode {
|
|
68
79
|
id: string;
|
|
69
80
|
type: string;
|
|
@@ -115,12 +126,30 @@ export interface MergeOptions {
|
|
|
115
126
|
*/
|
|
116
127
|
baseline?: AnyFlow;
|
|
117
128
|
/**
|
|
118
|
-
* Called once per finding, mirroring `DecompileOptions.warn`.
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
129
|
+
* Called once per finding, mirroring `DecompileOptions.warn`. The findings are:
|
|
130
|
+
*
|
|
131
|
+
* - a node whose `(type, typeVersion)` pair has no definition in either input,
|
|
132
|
+
* so `validate` will refuse the result;
|
|
133
|
+
* - with a `baseline`, each wire-identity difference between the baseline
|
|
134
|
+
* (the no-edit round trip) and the original — a node or edge added, dropped
|
|
135
|
+
* or rewired, a switch case id or human-task outcome id renamed, a binding
|
|
136
|
+
* row added or dropped — that the merged file still carries, i.e. the
|
|
137
|
+
* author's edit did not undo it;
|
|
138
|
+
* - an edited node whose `typeVersion` differs from the original node with
|
|
139
|
+
* the same id, because the edit took the SDK's version;
|
|
140
|
+
* - a merged edge whose `sourcePort` is not a handle its source node declares,
|
|
141
|
+
* and two merged binding rows for the same resource property.
|
|
122
142
|
*/
|
|
123
143
|
warn?: (message: string) => void;
|
|
144
|
+
/**
|
|
145
|
+
* Refuse instead of warn: when the merged file differs from the original
|
|
146
|
+
* where the author made no edit, throw {@link MergeRefusedError} listing every
|
|
147
|
+
* such finding, and return nothing. The other findings still go to `warn`.
|
|
148
|
+
* Two findings never refuse, because the merged file does what the original
|
|
149
|
+
* does: an End the compile appends to a tail the original leaves open, and a
|
|
150
|
+
* binding row the round trip drops (merge keeps the original's). Meant to be
|
|
151
|
+
* used with `baseline`; without one, only the merged-file checks can refuse.
|
|
152
|
+
*/
|
|
153
|
+
strict?: boolean;
|
|
124
154
|
}
|
|
125
155
|
export declare function mergeFlow(original: AnyFlow, edited: AnyFlow, options?: MergeOptions): AnyFlow;
|
|
126
|
-
export {};
|