@uipath/maestro-builder-sdk 6.58.0 → 6.58.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.
@@ -0,0 +1,180 @@
1
+ /**
2
+ * edit-scope — which parts of a Flow the author edited, and where a finding
3
+ * sits relative to that edit.
4
+ *
5
+ * A node or subflow of the edited flow that differs from its `baseline`
6
+ * counterpart (compile(decompile(original)), the no-edit round trip) is one
7
+ * the author changed; one that equals it is untouched. `mergeFlow` takes the
8
+ * changed ones from the edited flow and restores the untouched ones from the
9
+ * original; `flow merge --baseline-from` (merge-cli.ts) sorts each draft
10
+ * refusal and `check()` finding by the same rule, so a finding on an untouched
11
+ * node is a warning and one on an edited node is an error.
12
+ *
13
+ * For instance, a canvas Slack node with no connection yet that the author did
14
+ * not touch is `kept`; the same node with a new message text is `refused`.
15
+ *
16
+ * Pure: no file access, so a portable entry (`check.ts`, `merge.ts`) can use
17
+ * it. Reading the original and compiling its baseline is merge-compile.ts's.
18
+ */
19
+ import type { Diagnostic } from './check.js';
20
+ export interface AnyNode {
21
+ id: string;
22
+ type: string;
23
+ typeVersion?: string;
24
+ parentId?: string;
25
+ ui?: unknown;
26
+ display?: unknown;
27
+ inputs?: Record<string, unknown>;
28
+ outputs?: Record<string, unknown>;
29
+ [k: string]: unknown;
30
+ }
31
+ export interface AnyEdge {
32
+ id?: string;
33
+ sourceNodeId: string;
34
+ sourcePort: string;
35
+ targetNodeId: string;
36
+ targetPort: string;
37
+ [k: string]: unknown;
38
+ }
39
+ export interface AnyFlow {
40
+ id?: string;
41
+ name?: string;
42
+ version?: string;
43
+ nodes?: AnyNode[];
44
+ edges?: AnyEdge[];
45
+ definitions?: Array<{
46
+ nodeType?: string;
47
+ [k: string]: unknown;
48
+ }>;
49
+ variables?: unknown;
50
+ subflows?: Record<string, unknown>;
51
+ bindings?: Array<{
52
+ id?: string;
53
+ [k: string]: unknown;
54
+ }>;
55
+ metadata?: unknown;
56
+ solutionId?: unknown;
57
+ projectId?: unknown;
58
+ layout?: unknown;
59
+ runtime?: unknown;
60
+ [k: string]: unknown;
61
+ }
62
+ export declare function byId(nodes: AnyNode[] | undefined): Map<string, AnyNode>;
63
+ /**
64
+ * Whether two nodes are the SAME as far as the author is concerned — same type,
65
+ * same inputs and same output mappings. Layout (`ui`), `display`, `typeVersion`,
66
+ * and the derived parts of `outputs` (type, schema, description) are ignored,
67
+ * because those are exactly the parts decompile does not carry through. An
68
+ * output's `source` is authored, not derived: it is what an End returns, so an
69
+ * edit to `.return({ ... })` changes nothing else on the node. Used only to
70
+ * decide "did the user edit this node?" against the baseline.
71
+ */
72
+ export declare function nodeUnchanged(a: AnyNode | undefined, b: AnyNode | undefined): boolean;
73
+ /**
74
+ * The keys of the subflows `mergeFlow` (merge.ts) takes from `edited` rather than
75
+ * restoring from `original`: every subflow that is new, that the original does
76
+ * not have, or that differs from its `baseline` counterpart in any way the
77
+ * author can edit — a node's inputs, output sources or label, an edge, the
78
+ * scope's `variables` or `layout` (`subflowUnchanged`, #1322). With no
79
+ * baseline, that is every subflow. A kept subflow is the original's whole: its
80
+ * nodes, edges, variables and layout.
81
+ */
82
+ export declare function subflowsTakenFromEdited(original: AnyFlow, edited: AnyFlow, baseline?: AnyFlow): string[];
83
+ export type SubflowScope = {
84
+ nodes?: AnyNode[];
85
+ edges?: AnyEdge[];
86
+ variables?: unknown;
87
+ layout?: unknown;
88
+ };
89
+ /**
90
+ * Whether merge restores `e`'s ORIGINAL node instead of taking `e`: there is a
91
+ * baseline, the original has the node, and `e` equals its baseline counterpart.
92
+ * merge.ts's `mergeFlow` node loop and {@link nodesTakenFromEdited} both ask this.
93
+ */
94
+ export declare function isKept(e: AnyNode, origNodes: Map<string, AnyNode>, baseNodes: Map<string, AnyNode>, baseline: AnyFlow | undefined): boolean;
95
+ /**
96
+ * Why a finding is an error: `edited`, its node is one the author changed or
97
+ * added; `subflow`, it sits in a subflow the edit changed, which merge takes
98
+ * whole; `unplaced`, it names no node and the original's own round trip does
99
+ * not report it, so the edit introduced it.
100
+ */
101
+ export type TakenReason = 'edited' | 'subflow' | 'unplaced';
102
+ /**
103
+ * A finding on a node merge takes from the edited flow, with the reason it
104
+ * takes it: `edited`, the author changed or added the node; `subflow`, the
105
+ * node sits in a subflow the edit changed, which merge takes whole (`subflow`
106
+ * names it).
107
+ */
108
+ export type Refused<T> = T & {
109
+ reason: TakenReason;
110
+ subflow?: string;
111
+ };
112
+ /** A finding on a node merge restores from the original; `subflow` names the untouched subflow it sits in. */
113
+ type Kept<T> = T & {
114
+ subflow?: string;
115
+ };
116
+ /**
117
+ * Sort each finding by what merge does with its node: `refused` when the node
118
+ * comes from the edited flow (reason `edited`: the author changed or added it;
119
+ * reason `subflow`: it sits in a subflow the edit changed, which merge takes
120
+ * whole), `kept` when merge restores the original node — a root node the edit
121
+ * did not touch, or any node of a subflow the edit did not touch (#1322) — and
122
+ * `unplaced` when the finding names no node merge knows (an unnamed End's
123
+ * `.return`, a flow-level finding). The caller sorts those again with
124
+ * {@link splitUnplaced}: one the original's own round trip reports too is
125
+ * printed, one the edit introduced is refused.
126
+ *
127
+ * For instance a Slack node with no connection that the author did not touch
128
+ * is `kept`; the same node with a new message text is `refused` as `edited`.
129
+ */
130
+ export declare function classifyDrafts<T extends {
131
+ nodeId: string;
132
+ subflow?: string;
133
+ }>(original: any, edited: any, baseline: any, findings: readonly T[]): {
134
+ kept: Kept<T>[];
135
+ refused: Refused<T>[];
136
+ unplaced: T[];
137
+ };
138
+ /**
139
+ * A `check()` diagnostic, placed on the node its step emitted (`''` when it
140
+ * names no node); `subflow` is the calling step when the finding is inside a
141
+ * subflow, which then decides how it is classified.
142
+ */
143
+ export type PlacedDiagnostic = Diagnostic & {
144
+ nodeId: string;
145
+ subflow?: string;
146
+ };
147
+ /**
148
+ * Place each `check()` diagnostic on the node its step emitted in `editedFlow`, so
149
+ * {@link classifyDrafts} can sort it: a root step on its node (by label, then
150
+ * id); a step inside a subflow (`check` reports it as `<caller> › <step>`, one
151
+ * segment per level) on the deepest step the path resolves to, with `subflow`
152
+ * set to the deepest subflow on that path, which is what decides; a named End
153
+ * on its End node (#1322). A diagnostic with no step, or one no node answers
154
+ * to, gets nodeId `''`.
155
+ */
156
+ export declare function placeDiagnostics(diagnostics: readonly Diagnostic[], editedFlow: any): PlacedDiagnostic[];
157
+ /** How many times each {@link findingKey} occurs, for {@link splitUnplaced}'s baseline. */
158
+ export declare function findingCounts(findings: ReadonlyArray<{
159
+ code: string;
160
+ message: string;
161
+ }>): Map<string, number>;
162
+ /**
163
+ * Sort findings merge could not place on a node by whether the edit introduced
164
+ * them: `known` when the ORIGINAL's own round trip (`baseline`, the decompiled
165
+ * original compiled and checked, as {@link findingCounts}) reports the same
166
+ * code and message — once per occurrence, so a second copy of a finding the
167
+ * original has once is `introduced` — so the finding predates the edit and
168
+ * only prints; `introduced` otherwise, which is refused like any error on an
169
+ * edited node (#1322). With no baseline findings to compare against (a
170
+ * precompiled `--baseline` file), every one is `introduced`, as merge treated
171
+ * them before.
172
+ */
173
+ export declare function splitUnplaced<T extends {
174
+ code: string;
175
+ message: string;
176
+ }>(unplaced: readonly T[], baseline: ReadonlyMap<string, number> | undefined): {
177
+ known: T[];
178
+ introduced: T[];
179
+ };
180
+ export {};
@@ -0,0 +1,251 @@
1
+ export function byId(nodes) {
2
+ return new Map((nodes ?? []).map((n) => [n.id, n]));
3
+ }
4
+ /**
5
+ * Whether two nodes are the SAME as far as the author is concerned — same type,
6
+ * same inputs and same output mappings. Layout (`ui`), `display`, `typeVersion`,
7
+ * and the derived parts of `outputs` (type, schema, description) are ignored,
8
+ * because those are exactly the parts decompile does not carry through. An
9
+ * output's `source` is authored, not derived: it is what an End returns, so an
10
+ * edit to `.return({ ... })` changes nothing else on the node. Used only to
11
+ * decide "did the user edit this node?" against the baseline.
12
+ */
13
+ export function nodeUnchanged(a, b) {
14
+ if (!a || !b)
15
+ return false;
16
+ if (a.type !== b.type)
17
+ return false;
18
+ return JSON.stringify(a.inputs ?? {}) === JSON.stringify(b.inputs ?? {})
19
+ && JSON.stringify(outputSources(a)) === JSON.stringify(outputSources(b));
20
+ }
21
+ /** Each output's `source` by output name; outputs without one are left out. */
22
+ function outputSources(n) {
23
+ const sources = {};
24
+ for (const [name, output] of Object.entries(n.outputs ?? {})) {
25
+ if (output && typeof output === 'object' && 'source' in output)
26
+ sources[name] = output.source;
27
+ }
28
+ return sources;
29
+ }
30
+ /**
31
+ * The ids of the root nodes `mergeFlow` (merge.ts) takes from `edited` rather than
32
+ * restoring from `original`: every node that is new, or that differs from its
33
+ * `baseline` counterpart. With no baseline, that is every node.
34
+ *
35
+ * For instance, with a baseline equal to `edited` and every node present in
36
+ * `original`, the answer is `[]` — a no-edit merge keeps them all.
37
+ */
38
+ function nodesTakenFromEdited(original, edited, baseline) {
39
+ const origNodes = byId(original.nodes);
40
+ const baseNodes = byId(baseline?.nodes);
41
+ return (edited.nodes ?? []).filter((e) => !isKept(e, origNodes, baseNodes, baseline)).map((e) => e.id);
42
+ }
43
+ /**
44
+ * The keys of the subflows `mergeFlow` (merge.ts) takes from `edited` rather than
45
+ * restoring from `original`: every subflow that is new, that the original does
46
+ * not have, or that differs from its `baseline` counterpart in any way the
47
+ * author can edit — a node's inputs, output sources or label, an edge, the
48
+ * scope's `variables` or `layout` (`subflowUnchanged`, #1322). With no
49
+ * baseline, that is every subflow. A kept subflow is the original's whole: its
50
+ * nodes, edges, variables and layout.
51
+ */
52
+ export function subflowsTakenFromEdited(original, edited, baseline) {
53
+ return Object.keys(edited.subflows ?? {}).filter((key) => {
54
+ const orig = original.subflows?.[key];
55
+ const base = baseline?.subflows?.[key];
56
+ return !baseline || orig === undefined || base === undefined
57
+ || !subflowUnchanged(edited.subflows[key], base);
58
+ });
59
+ }
60
+ /** A node's canvas label, when it has one. */
61
+ const labelOf = (n) => n?.display?.label;
62
+ /**
63
+ * Whether an edited subflow equals its baseline: the same node ids, each node
64
+ * unchanged (`nodeUnchanged`) and under the same label, the same edges by
65
+ * their endpoints and handles (ids are compile's own), and the same
66
+ * `variables` and `layout` byte for byte. The scope's variables carry what the
67
+ * nodes do not — a step's `{ updates }` row, a child `.input()` type, a `.var()`
68
+ * default — and a kept subflow is the original's whole, so any of them changing
69
+ * has to make the subflow a taken one.
70
+ */
71
+ function subflowUnchanged(e, b) {
72
+ const eNodes = byId(e.nodes);
73
+ const bNodes = byId(b.nodes);
74
+ if (eNodes.size !== bNodes.size)
75
+ return false;
76
+ for (const [id, n] of eNodes) {
77
+ const base = bNodes.get(id);
78
+ if (!nodeUnchanged(n, base) || labelOf(n) !== labelOf(base))
79
+ return false;
80
+ }
81
+ if (JSON.stringify(e.variables ?? null) !== JSON.stringify(b.variables ?? null))
82
+ return false;
83
+ // `layout.edges` is keyed by edge id, and ids come from one file-wide
84
+ // counter: a step added before the call shifts every id in the subflow
85
+ // without touching it. So routes are compared by the endpoints the id names
86
+ // (as the edges themselves are), and `layout.nodes` as it is.
87
+ if (JSON.stringify(layoutByEndpoints(e)) !== JSON.stringify(layoutByEndpoints(b)))
88
+ return false;
89
+ const eEdges = (e.edges ?? []).map(edgeKey).sort();
90
+ const bEdges = (b.edges ?? []).map(edgeKey).sort();
91
+ return eEdges.length === bEdges.length && eEdges.every((k, i) => k === bEdges[i]);
92
+ }
93
+ /** `<source>@<port>-><target>@<port>`: an edge's identity apart from its id. */
94
+ const edgeKey = (x) => `${x.sourceNodeId}@${x.sourcePort}->${x.targetNodeId}@${x.targetPort}`;
95
+ /** A scope's layout with its `edges` re-keyed by endpoints, so an id shift does not read as a change. */
96
+ function layoutByEndpoints(sf) {
97
+ const layout = sf.layout;
98
+ if (!layout || typeof layout !== 'object')
99
+ return layout ?? null;
100
+ const byId = new Map((sf.edges ?? []).map((x) => [x.id, edgeKey(x)]));
101
+ const { edges, ...rest } = layout;
102
+ const routes = Object.entries(edges ?? {}).map(([id, route]) => [byId.get(id) ?? id, route]).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
103
+ return { ...rest, edges: Object.fromEntries(routes) };
104
+ }
105
+ /**
106
+ * Whether merge restores `e`'s ORIGINAL node instead of taking `e`: there is a
107
+ * baseline, the original has the node, and `e` equals its baseline counterpart.
108
+ * merge.ts's `mergeFlow` node loop and {@link nodesTakenFromEdited} both ask this.
109
+ */
110
+ export function isKept(e, origNodes, baseNodes, baseline) {
111
+ return !!baseline && origNodes.has(e.id) && nodeUnchanged(e, baseNodes.get(e.id));
112
+ }
113
+ /**
114
+ * Sort each finding by what merge does with its node: `refused` when the node
115
+ * comes from the edited flow (reason `edited`: the author changed or added it;
116
+ * reason `subflow`: it sits in a subflow the edit changed, which merge takes
117
+ * whole), `kept` when merge restores the original node — a root node the edit
118
+ * did not touch, or any node of a subflow the edit did not touch (#1322) — and
119
+ * `unplaced` when the finding names no node merge knows (an unnamed End's
120
+ * `.return`, a flow-level finding). The caller sorts those again with
121
+ * {@link splitUnplaced}: one the original's own round trip reports too is
122
+ * printed, one the edit introduced is refused.
123
+ *
124
+ * For instance a Slack node with no connection that the author did not touch
125
+ * is `kept`; the same node with a new message text is `refused` as `edited`.
126
+ */
127
+ export function classifyDrafts(original, edited, baseline, findings) {
128
+ const fromEdited = new Set(nodesTakenFromEdited(original, edited, baseline));
129
+ const takenSubflows = new Set(subflowsTakenFromEdited(original, edited, baseline));
130
+ const rootIds = new Set((edited?.nodes ?? []).map((n) => n.id));
131
+ /** A subflow's own node ids → the key (the calling step) of the subflow holding them. */
132
+ const subflowOfNode = new Map();
133
+ for (const [key, sf] of Object.entries(edited?.subflows ?? {})) {
134
+ for (const n of sf?.nodes ?? [])
135
+ subflowOfNode.set(n.id, key);
136
+ }
137
+ const kept = [];
138
+ const refused = [];
139
+ const unplaced = [];
140
+ for (const f of findings) {
141
+ // A finding inside a subflow — placed on the calling step by `checkEdited`
142
+ // (`subflow` set), or a draft on one of the subflow's own nodes — is judged
143
+ // by the SUBFLOW: taken whole when the edit changed it, else the original's.
144
+ const subflow = f.subflow ?? subflowOfNode.get(f.nodeId);
145
+ if (subflow !== undefined) {
146
+ if (takenSubflows.has(subflow))
147
+ refused.push({ ...f, reason: 'subflow', subflow });
148
+ else
149
+ kept.push({ ...f, subflow });
150
+ }
151
+ else if (rootIds.has(f.nodeId)) {
152
+ if (fromEdited.has(f.nodeId))
153
+ refused.push({ ...f, reason: 'edited' });
154
+ else
155
+ kept.push(f);
156
+ }
157
+ else {
158
+ unplaced.push(f);
159
+ }
160
+ }
161
+ return { kept, refused, unplaced };
162
+ }
163
+ /**
164
+ * Place each `check()` diagnostic on the node its step emitted in `editedFlow`, so
165
+ * {@link classifyDrafts} can sort it: a root step on its node (by label, then
166
+ * id); a step inside a subflow (`check` reports it as `<caller> › <step>`, one
167
+ * segment per level) on the deepest step the path resolves to, with `subflow`
168
+ * set to the deepest subflow on that path, which is what decides; a named End
169
+ * on its End node (#1322). A diagnostic with no step, or one no node answers
170
+ * to, gets nodeId `''`.
171
+ */
172
+ export function placeDiagnostics(diagnostics, editedFlow) {
173
+ const byStep = new Map();
174
+ for (const n of editedFlow?.nodes ?? []) {
175
+ const label = n?.display?.label;
176
+ if (typeof label === 'string' && !byStep.has(label))
177
+ byStep.set(label, n.id);
178
+ }
179
+ const ids = new Set((editedFlow?.nodes ?? []).map((n) => n.id));
180
+ /** `name` as a node id in `nodes`: by label, then by id; `''` when neither answers. */
181
+ const resolveIn = (nodes, name) => {
182
+ const byLabel = nodes.find((n) => n?.display?.label === name);
183
+ if (byLabel)
184
+ return byLabel.id;
185
+ return nodes.some((n) => n?.id === name) ? name : '';
186
+ };
187
+ // `subflows` is flat, keyed by each caller's id at every depth, and check
188
+ // prefixes a finding at every level (`outer › inner › x`): the subflow that
189
+ // decides is the DEEPEST one on the path, since merge keeps or takes each
190
+ // subflow on its own (an edit inside `inner` leaves `outer` kept).
191
+ const place = (step) => {
192
+ if (step === undefined)
193
+ return { nodeId: '' };
194
+ const segments = step.split(SUBFLOW_STEP_SEPARATOR);
195
+ let scope = editedFlow?.nodes ?? [];
196
+ let subflow;
197
+ let nodeId = '';
198
+ for (const [i, segment] of segments.entries()) {
199
+ const id = i === 0 ? (byStep.get(segment) ?? (ids.has(segment) ? segment : '')) : resolveIn(scope, segment);
200
+ if (id === '')
201
+ break;
202
+ nodeId = id;
203
+ const child = i < segments.length - 1 ? editedFlow?.subflows?.[id] : undefined;
204
+ if (child === undefined)
205
+ break;
206
+ subflow = id;
207
+ scope = child.nodes ?? [];
208
+ }
209
+ return subflow !== undefined ? { nodeId: nodeId === '' ? subflow : nodeId, subflow } : { nodeId };
210
+ };
211
+ return diagnostics.map((d) => ({ ...d, ...place(d.step) }));
212
+ }
213
+ /** The identity of a finding for the introduced-or-not comparison: its code and message. */
214
+ function findingKey(f) {
215
+ return `${f.code}\n${f.message}`;
216
+ }
217
+ /** How many times each {@link findingKey} occurs, for {@link splitUnplaced}'s baseline. */
218
+ export function findingCounts(findings) {
219
+ const counts = new Map();
220
+ for (const f of findings)
221
+ counts.set(findingKey(f), (counts.get(findingKey(f)) ?? 0) + 1);
222
+ return counts;
223
+ }
224
+ /**
225
+ * Sort findings merge could not place on a node by whether the edit introduced
226
+ * them: `known` when the ORIGINAL's own round trip (`baseline`, the decompiled
227
+ * original compiled and checked, as {@link findingCounts}) reports the same
228
+ * code and message — once per occurrence, so a second copy of a finding the
229
+ * original has once is `introduced` — so the finding predates the edit and
230
+ * only prints; `introduced` otherwise, which is refused like any error on an
231
+ * edited node (#1322). With no baseline findings to compare against (a
232
+ * precompiled `--baseline` file), every one is `introduced`, as merge treated
233
+ * them before.
234
+ */
235
+ export function splitUnplaced(unplaced, baseline) {
236
+ const known = [];
237
+ const introduced = [];
238
+ const left = new Map(baseline ?? []);
239
+ for (const f of unplaced) {
240
+ const n = left.get(findingKey(f)) ?? 0;
241
+ if (n > 0) {
242
+ left.set(findingKey(f), n - 1);
243
+ known.push(f);
244
+ }
245
+ else
246
+ introduced.push(f);
247
+ }
248
+ return { known, introduced };
249
+ }
250
+ /** How `check` joins a subflow's calling step and the step inside it in a diagnostic's `step`. */
251
+ const SUBFLOW_STEP_SEPARATOR = ' › ';
package/dist/merge-cli.js CHANGED
@@ -9,7 +9,8 @@
9
9
  * `--baseline-from <original.flow>` makes merge build the baseline itself
10
10
  * (decompile + compile, nothing written), and an edited `.flow.ts` makes merge
11
11
  * compile it: both compiles tolerate DRAFT nodes, and merge refuses a draft
12
- * only where it would take the node from the edited flow. See merge-compile.ts.
12
+ * only where it would take the node from the edited flow. See merge-compile.ts
13
+ * and edit-scope.ts.
13
14
  *
14
15
  * `edited.flow` is compile(decompile(original) after your edits). `--baseline`
15
16
  * is compile(decompile(original)) BEFORE any edit — supplying it enables the
@@ -55,7 +56,8 @@ import { flowCheckOpts, openCliLibrary } from './cli-library.js';
55
56
  import { Bindings } from './bindings.js';
56
57
  import { defaultBindingsFile } from './workdir.js';
57
58
  import { loadNodePackages } from './node-packages.js';
58
- import { baselineFromOriginal, checkEdited, classifyDrafts, compileSourceDraftTolerant, findingCounts, splitUnplaced } from './merge-compile.js';
59
+ import { baselineFromOriginal, checkEdited, compileSourceDraftTolerant } from './merge-compile.js';
60
+ import { classifyDrafts, findingCounts, splitUnplaced } from './edit-scope.js';
59
61
  const USAGE = 'usage: flow-merge <original.flow> <edited.flow | Edited.flow.ts> [-o merged.flow] '
60
62
  + '[--baseline <baseline.flow> | --baseline-from <original.flow>] [--library <dir>] [--bindings <file>] [--strict]';
61
63
  /**
@@ -1,5 +1,6 @@
1
1
  import { type DraftFinding, type SerializeOpts } from './serialize.js';
2
- import { type CheckOpts, type Diagnostic } from './check.js';
2
+ import { type CheckOpts } from './check.js';
3
+ import { type PlacedDiagnostic } from './edit-scope.js';
3
4
  import type { BuiltFlow } from './flow-sdk.js';
4
5
  /**
5
6
  * A compile's flow, the draft refusals it reported instead of throwing, and the
@@ -30,94 +31,10 @@ export declare function compileSourceDraftTolerant(file: string, opts?: Omit<Ser
30
31
  export declare function baselineFromOriginal(originalPath: string, opts?: Omit<SerializeOpts, 'draft'>, warn?: (message: string) => void): Promise<DraftTolerantCompile & {
31
32
  built: BuiltFlow;
32
33
  }>;
33
- /**
34
- * Why a finding is an error: `edited`, its node is one the author changed or
35
- * added; `subflow`, it sits in a subflow the edit changed, which merge takes
36
- * whole; `unplaced`, it names no node and the original's own round trip does
37
- * not report it, so the edit introduced it.
38
- */
39
- export type TakenReason = 'edited' | 'subflow' | 'unplaced';
40
- /**
41
- * A finding on a node merge takes from the edited flow, with the reason it
42
- * takes it: `edited`, the author changed or added the node; `subflow`, the
43
- * node sits in a subflow the edit changed, which merge takes whole (`subflow`
44
- * names it).
45
- */
46
- export type Refused<T> = T & {
47
- reason: TakenReason;
48
- subflow?: string;
49
- };
50
- /** A finding on a node merge restores from the original; `subflow` names the untouched subflow it sits in. */
51
- export type Kept<T> = T & {
52
- subflow?: string;
53
- };
54
- /**
55
- * Sort each finding by what merge does with its node: `refused` when the node
56
- * comes from the edited flow (reason `edited`: the author changed or added it;
57
- * reason `subflow`: it sits in a subflow the edit changed, which merge takes
58
- * whole), `kept` when merge restores the original node — a root node the edit
59
- * did not touch, or any node of a subflow the edit did not touch (#1322) — and
60
- * `unplaced` when the finding names no node merge knows (an unnamed End's
61
- * `.return`, a flow-level finding). The caller sorts those again with
62
- * {@link splitUnplaced}: one the original's own round trip reports too is
63
- * printed, one the edit introduced is refused.
64
- *
65
- * For instance a Slack node with no connection that the author did not touch
66
- * is `kept`; the same node with a new message text is `refused` as `edited`.
67
- */
68
- export declare function classifyDrafts<T extends {
69
- nodeId: string;
70
- subflow?: string;
71
- }>(original: any, edited: any, baseline: any, findings: readonly T[]): {
72
- kept: Kept<T>[];
73
- refused: Refused<T>[];
74
- unplaced: T[];
75
- };
76
- /**
77
- * A `check()` diagnostic, placed on the node its step emitted (`''` when it
78
- * names no node); `subflow` is the calling step when the finding is inside a
79
- * subflow, which then decides how it is classified.
80
- */
81
- export type PlacedDiagnostic = Diagnostic & {
82
- nodeId: string;
83
- subflow?: string;
84
- };
85
34
  /**
86
35
  * Run `check()` on the edited source, as `compile` does, and place each
87
- * diagnostic on the node its step emitted in `editedFlow`, so
88
- * {@link classifyDrafts} can sort it: a root step on its node (by label, then
89
- * id); a step inside a subflow (`check` reports it as `<caller> › <step>`, one
90
- * segment per level) on the deepest step the path resolves to, with `subflow`
91
- * set to the deepest subflow on that path, which is what decides; a named End
92
- * on its End node (#1322). A diagnostic with no step, or one no node answers
93
- * to, gets nodeId `''`.
36
+ * diagnostic on its node ({@link placeDiagnostics}). The `check()` call stays
37
+ * here, not in `edit-scope.ts`, so `check.ts` can import that module without a
38
+ * load-time cycle.
94
39
  */
95
40
  export declare function checkEdited(built: BuiltFlow, editedFlow: any, opts: CheckOpts): PlacedDiagnostic[];
96
- /** The identity of a finding for the introduced-or-not comparison: its code and message. */
97
- export declare function findingKey(f: {
98
- code: string;
99
- message: string;
100
- }): string;
101
- /** How many times each {@link findingKey} occurs, for {@link splitUnplaced}'s baseline. */
102
- export declare function findingCounts(findings: ReadonlyArray<{
103
- code: string;
104
- message: string;
105
- }>): Map<string, number>;
106
- /**
107
- * Sort findings merge could not place on a node by whether the edit introduced
108
- * them: `known` when the ORIGINAL's own round trip (`baseline`, the decompiled
109
- * original compiled and checked, as {@link findingCounts}) reports the same
110
- * code and message — once per occurrence, so a second copy of a finding the
111
- * original has once is `introduced` — so the finding predates the edit and
112
- * only prints; `introduced` otherwise, which is refused like any error on an
113
- * edited node (#1322). With no baseline findings to compare against (a
114
- * precompiled `--baseline` file), every one is `introduced`, as merge treated
115
- * them before.
116
- */
117
- export declare function splitUnplaced<T extends {
118
- code: string;
119
- message: string;
120
- }>(unplaced: readonly T[], baseline: ReadonlyMap<string, number> | undefined): {
121
- known: T[];
122
- introduced: T[];
123
- };
@@ -4,7 +4,8 @@
4
4
  * Both compiles tolerate draft nodes (`SerializeOpts.draft`). A draft on a node
5
5
  * merge keeps from the original is a warning; a draft or `check()` error on a
6
6
  * node merge takes from the edited flow is an error. Nothing draft-tolerant is
7
- * written to disk.
7
+ * written to disk. Which nodes count as edited, and how each finding is sorted,
8
+ * is edit-scope.ts's.
8
9
  */
9
10
  import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
10
11
  import { tmpdir } from 'node:os';
@@ -12,9 +13,9 @@ import { dirname, join, resolve } from 'node:path';
12
13
  import { decompile } from './decompile.js';
13
14
  import { loadBuiltFlow } from './load.js';
14
15
  import { serialize } from './serialize.js';
15
- import { check } from './check.js';
16
- import { nodesTakenFromEdited, subflowsTakenFromEdited } from './merge.js';
17
16
  import { PACKAGE_NAME } from './package-name.js';
17
+ import { check } from './check.js';
18
+ import { placeDiagnostics } from './edit-scope.js';
18
19
  /** Compile `built` with draft tolerance. Every other refusal still throws. */
19
20
  export function compileDraftTolerant(built, opts = {}) {
20
21
  const drafts = [];
@@ -73,143 +74,12 @@ function baselineDirectory(originalPath) {
73
74
  return mkdtempSync(join(tmpdir(), 'flow-merge-baseline-'));
74
75
  }
75
76
  }
76
- /**
77
- * Sort each finding by what merge does with its node: `refused` when the node
78
- * comes from the edited flow (reason `edited`: the author changed or added it;
79
- * reason `subflow`: it sits in a subflow the edit changed, which merge takes
80
- * whole), `kept` when merge restores the original node — a root node the edit
81
- * did not touch, or any node of a subflow the edit did not touch (#1322) — and
82
- * `unplaced` when the finding names no node merge knows (an unnamed End's
83
- * `.return`, a flow-level finding). The caller sorts those again with
84
- * {@link splitUnplaced}: one the original's own round trip reports too is
85
- * printed, one the edit introduced is refused.
86
- *
87
- * For instance a Slack node with no connection that the author did not touch
88
- * is `kept`; the same node with a new message text is `refused` as `edited`.
89
- */
90
- export function classifyDrafts(original, edited, baseline, findings) {
91
- const fromEdited = new Set(nodesTakenFromEdited(original, edited, baseline));
92
- const takenSubflows = new Set(subflowsTakenFromEdited(original, edited, baseline));
93
- const rootIds = new Set((edited?.nodes ?? []).map((n) => n.id));
94
- /** A subflow's own node ids → the key (the calling step) of the subflow holding them. */
95
- const subflowOfNode = new Map();
96
- for (const [key, sf] of Object.entries(edited?.subflows ?? {})) {
97
- for (const n of sf?.nodes ?? [])
98
- subflowOfNode.set(n.id, key);
99
- }
100
- const kept = [];
101
- const refused = [];
102
- const unplaced = [];
103
- for (const f of findings) {
104
- // A finding inside a subflow — placed on the calling step by `checkEdited`
105
- // (`subflow` set), or a draft on one of the subflow's own nodes — is judged
106
- // by the SUBFLOW: taken whole when the edit changed it, else the original's.
107
- const subflow = f.subflow ?? subflowOfNode.get(f.nodeId);
108
- if (subflow !== undefined) {
109
- if (takenSubflows.has(subflow))
110
- refused.push({ ...f, reason: 'subflow', subflow });
111
- else
112
- kept.push({ ...f, subflow });
113
- }
114
- else if (rootIds.has(f.nodeId)) {
115
- if (fromEdited.has(f.nodeId))
116
- refused.push({ ...f, reason: 'edited' });
117
- else
118
- kept.push(f);
119
- }
120
- else {
121
- unplaced.push(f);
122
- }
123
- }
124
- return { kept, refused, unplaced };
125
- }
126
77
  /**
127
78
  * Run `check()` on the edited source, as `compile` does, and place each
128
- * diagnostic on the node its step emitted in `editedFlow`, so
129
- * {@link classifyDrafts} can sort it: a root step on its node (by label, then
130
- * id); a step inside a subflow (`check` reports it as `<caller> › <step>`, one
131
- * segment per level) on the deepest step the path resolves to, with `subflow`
132
- * set to the deepest subflow on that path, which is what decides; a named End
133
- * on its End node (#1322). A diagnostic with no step, or one no node answers
134
- * to, gets nodeId `''`.
79
+ * diagnostic on its node ({@link placeDiagnostics}). The `check()` call stays
80
+ * here, not in `edit-scope.ts`, so `check.ts` can import that module without a
81
+ * load-time cycle.
135
82
  */
136
83
  export function checkEdited(built, editedFlow, opts) {
137
- const byStep = new Map();
138
- for (const n of editedFlow?.nodes ?? []) {
139
- const label = n?.display?.label;
140
- if (typeof label === 'string' && !byStep.has(label))
141
- byStep.set(label, n.id);
142
- }
143
- const ids = new Set((editedFlow?.nodes ?? []).map((n) => n.id));
144
- /** `name` as a node id in `nodes`: by label, then by id; `''` when neither answers. */
145
- const resolveIn = (nodes, name) => {
146
- const byLabel = nodes.find((n) => n?.display?.label === name);
147
- if (byLabel)
148
- return byLabel.id;
149
- return nodes.some((n) => n?.id === name) ? name : '';
150
- };
151
- // `subflows` is flat, keyed by each caller's id at every depth, and check
152
- // prefixes a finding at every level (`outer › inner › x`): the subflow that
153
- // decides is the DEEPEST one on the path, since merge keeps or takes each
154
- // subflow on its own (an edit inside `inner` leaves `outer` kept).
155
- const place = (step) => {
156
- if (step === undefined)
157
- return { nodeId: '' };
158
- const segments = step.split(SUBFLOW_STEP_SEPARATOR);
159
- let scope = editedFlow?.nodes ?? [];
160
- let subflow;
161
- let nodeId = '';
162
- for (const [i, segment] of segments.entries()) {
163
- const id = i === 0 ? (byStep.get(segment) ?? (ids.has(segment) ? segment : '')) : resolveIn(scope, segment);
164
- if (id === '')
165
- break;
166
- nodeId = id;
167
- const child = i < segments.length - 1 ? editedFlow?.subflows?.[id] : undefined;
168
- if (child === undefined)
169
- break;
170
- subflow = id;
171
- scope = child.nodes ?? [];
172
- }
173
- return subflow !== undefined ? { nodeId: nodeId === '' ? subflow : nodeId, subflow } : { nodeId };
174
- };
175
- return check(built, opts).map((d) => ({ ...d, ...place(d.step) }));
176
- }
177
- /** The identity of a finding for the introduced-or-not comparison: its code and message. */
178
- export function findingKey(f) {
179
- return `${f.code}\n${f.message}`;
180
- }
181
- /** How many times each {@link findingKey} occurs, for {@link splitUnplaced}'s baseline. */
182
- export function findingCounts(findings) {
183
- const counts = new Map();
184
- for (const f of findings)
185
- counts.set(findingKey(f), (counts.get(findingKey(f)) ?? 0) + 1);
186
- return counts;
187
- }
188
- /**
189
- * Sort findings merge could not place on a node by whether the edit introduced
190
- * them: `known` when the ORIGINAL's own round trip (`baseline`, the decompiled
191
- * original compiled and checked, as {@link findingCounts}) reports the same
192
- * code and message — once per occurrence, so a second copy of a finding the
193
- * original has once is `introduced` — so the finding predates the edit and
194
- * only prints; `introduced` otherwise, which is refused like any error on an
195
- * edited node (#1322). With no baseline findings to compare against (a
196
- * precompiled `--baseline` file), every one is `introduced`, as merge treated
197
- * them before.
198
- */
199
- export function splitUnplaced(unplaced, baseline) {
200
- const known = [];
201
- const introduced = [];
202
- const left = new Map(baseline ?? []);
203
- for (const f of unplaced) {
204
- const n = left.get(findingKey(f)) ?? 0;
205
- if (n > 0) {
206
- left.set(findingKey(f), n - 1);
207
- known.push(f);
208
- }
209
- else
210
- introduced.push(f);
211
- }
212
- return { known, introduced };
84
+ return placeDiagnostics(check(built, opts), editedFlow);
213
85
  }
214
- /** How `check` joins a subflow's calling step and the step inside it in a diagnostic's `step`. */
215
- const SUBFLOW_STEP_SEPARATOR = ' › ';
package/dist/merge.d.ts CHANGED
@@ -87,50 +87,9 @@
87
87
  * original plus the author's change to the node. See merge-embedded-agent.ts.
88
88
  */
89
89
  import { type MergeSidecarSources, type SidecarWrite } from './merge-sidecars.js';
90
+ import { type AnyFlow } from './edit-scope.js';
90
91
  export { MergeRefusedError, type MergeFinding } from './merge-roundtrip.js';
91
92
  export type { MergeSidecarSources, SidecarSet, SidecarWrite } from './merge-sidecars.js';
92
- interface AnyNode {
93
- id: string;
94
- type: string;
95
- typeVersion?: string;
96
- parentId?: string;
97
- ui?: unknown;
98
- display?: unknown;
99
- inputs?: Record<string, unknown>;
100
- outputs?: Record<string, unknown>;
101
- [k: string]: unknown;
102
- }
103
- interface AnyEdge {
104
- id?: string;
105
- sourceNodeId: string;
106
- sourcePort: string;
107
- targetNodeId: string;
108
- targetPort: string;
109
- [k: string]: unknown;
110
- }
111
- interface AnyFlow {
112
- id?: string;
113
- name?: string;
114
- version?: string;
115
- nodes?: AnyNode[];
116
- edges?: AnyEdge[];
117
- definitions?: Array<{
118
- nodeType?: string;
119
- [k: string]: unknown;
120
- }>;
121
- variables?: unknown;
122
- subflows?: Record<string, unknown>;
123
- bindings?: Array<{
124
- id?: string;
125
- [k: string]: unknown;
126
- }>;
127
- metadata?: unknown;
128
- solutionId?: unknown;
129
- projectId?: unknown;
130
- layout?: unknown;
131
- runtime?: unknown;
132
- [k: string]: unknown;
133
- }
134
93
  export interface MergeOptions {
135
94
  /**
136
95
  * compile(decompile(original)) captured BEFORE the user's edits. Enables the
@@ -172,25 +131,6 @@ export interface MergeOptions {
172
131
  */
173
132
  strict?: boolean;
174
133
  }
175
- /**
176
- * The ids of the root nodes {@link mergeFlow} takes from `edited` rather than
177
- * restoring from `original`: every node that is new, or that differs from its
178
- * `baseline` counterpart. With no baseline, that is every node.
179
- *
180
- * For instance, with a baseline equal to `edited` and every node present in
181
- * `original`, the answer is `[]` — a no-edit merge keeps them all.
182
- */
183
- export declare function nodesTakenFromEdited(original: AnyFlow, edited: AnyFlow, baseline?: AnyFlow): string[];
184
- /**
185
- * The keys of the subflows {@link mergeFlow} takes from `edited` rather than
186
- * restoring from `original`: every subflow that is new, that the original does
187
- * not have, or that differs from its `baseline` counterpart in any way the
188
- * author can edit — a node's inputs, output sources or label, an edge, the
189
- * scope's `variables` or `layout` (`subflowUnchanged`, #1322). With no
190
- * baseline, that is every subflow. A kept subflow is the original's whole: its
191
- * nodes, edges, variables and layout.
192
- */
193
- export declare function subflowsTakenFromEdited(original: AnyFlow, edited: AnyFlow, baseline?: AnyFlow): string[];
194
134
  export declare function mergeFlow(original: AnyFlow, edited: AnyFlow, options?: MergeOptions): AnyFlow;
195
135
  /**
196
136
  * `mergeFlow`, plus the agent sidecar files the merged flow needs written beside
package/dist/merge.js CHANGED
@@ -95,119 +95,8 @@ import { placeAddedNodes } from './node-placement.js';
95
95
  import { mergeAgentSidecars } from './merge-sidecars.js';
96
96
  import { embeddedAgentFindings, isEmbeddedAgent, mergeEmbeddedAgentNode } from './merge-embedded-agent.js';
97
97
  import { mergeObject, mergeVariables, stableJson } from './merge-variables.js';
98
+ import { byId, isKept, nodeUnchanged, subflowsTakenFromEdited } from './edit-scope.js';
98
99
  export { MergeRefusedError } from './merge-roundtrip.js';
99
- function byId(nodes) {
100
- return new Map((nodes ?? []).map((n) => [n.id, n]));
101
- }
102
- /**
103
- * Whether two nodes are the SAME as far as the author is concerned — same type,
104
- * same inputs and same output mappings. Layout (`ui`), `display`, `typeVersion`,
105
- * and the derived parts of `outputs` (type, schema, description) are ignored,
106
- * because those are exactly the parts decompile does not carry through. An
107
- * output's `source` is authored, not derived: it is what an End returns, so an
108
- * edit to `.return({ ... })` changes nothing else on the node. Used only to
109
- * decide "did the user edit this node?" against the baseline.
110
- */
111
- function nodeUnchanged(a, b) {
112
- if (!a || !b)
113
- return false;
114
- if (a.type !== b.type)
115
- return false;
116
- return JSON.stringify(a.inputs ?? {}) === JSON.stringify(b.inputs ?? {})
117
- && JSON.stringify(outputSources(a)) === JSON.stringify(outputSources(b));
118
- }
119
- /** Each output's `source` by output name; outputs without one are left out. */
120
- function outputSources(n) {
121
- const sources = {};
122
- for (const [name, output] of Object.entries(n.outputs ?? {})) {
123
- if (output && typeof output === 'object' && 'source' in output)
124
- sources[name] = output.source;
125
- }
126
- return sources;
127
- }
128
- /**
129
- * The ids of the root nodes {@link mergeFlow} takes from `edited` rather than
130
- * restoring from `original`: every node that is new, or that differs from its
131
- * `baseline` counterpart. With no baseline, that is every node.
132
- *
133
- * For instance, with a baseline equal to `edited` and every node present in
134
- * `original`, the answer is `[]` — a no-edit merge keeps them all.
135
- */
136
- export function nodesTakenFromEdited(original, edited, baseline) {
137
- const origNodes = byId(original.nodes);
138
- const baseNodes = byId(baseline?.nodes);
139
- return (edited.nodes ?? []).filter((e) => !isKept(e, origNodes, baseNodes, baseline)).map((e) => e.id);
140
- }
141
- /**
142
- * The keys of the subflows {@link mergeFlow} takes from `edited` rather than
143
- * restoring from `original`: every subflow that is new, that the original does
144
- * not have, or that differs from its `baseline` counterpart in any way the
145
- * author can edit — a node's inputs, output sources or label, an edge, the
146
- * scope's `variables` or `layout` (`subflowUnchanged`, #1322). With no
147
- * baseline, that is every subflow. A kept subflow is the original's whole: its
148
- * nodes, edges, variables and layout.
149
- */
150
- export function subflowsTakenFromEdited(original, edited, baseline) {
151
- return Object.keys(edited.subflows ?? {}).filter((key) => {
152
- const orig = original.subflows?.[key];
153
- const base = baseline?.subflows?.[key];
154
- return !baseline || orig === undefined || base === undefined
155
- || !subflowUnchanged(edited.subflows[key], base);
156
- });
157
- }
158
- /** A node's canvas label, when it has one. */
159
- const labelOf = (n) => n?.display?.label;
160
- /**
161
- * Whether an edited subflow equals its baseline: the same node ids, each node
162
- * unchanged (`nodeUnchanged`) and under the same label, the same edges by
163
- * their endpoints and handles (ids are compile's own), and the same
164
- * `variables` and `layout` byte for byte. The scope's variables carry what the
165
- * nodes do not — a step's `{ updates }` row, a child `.input()` type, a `.var()`
166
- * default — and a kept subflow is the original's whole, so any of them changing
167
- * has to make the subflow a taken one.
168
- */
169
- function subflowUnchanged(e, b) {
170
- const eNodes = byId(e.nodes);
171
- const bNodes = byId(b.nodes);
172
- if (eNodes.size !== bNodes.size)
173
- return false;
174
- for (const [id, n] of eNodes) {
175
- const base = bNodes.get(id);
176
- if (!nodeUnchanged(n, base) || labelOf(n) !== labelOf(base))
177
- return false;
178
- }
179
- if (JSON.stringify(e.variables ?? null) !== JSON.stringify(b.variables ?? null))
180
- return false;
181
- // `layout.edges` is keyed by edge id, and ids come from one file-wide
182
- // counter: a step added before the call shifts every id in the subflow
183
- // without touching it. So routes are compared by the endpoints the id names
184
- // (as the edges themselves are), and `layout.nodes` as it is.
185
- if (JSON.stringify(layoutByEndpoints(e)) !== JSON.stringify(layoutByEndpoints(b)))
186
- return false;
187
- const eEdges = (e.edges ?? []).map(edgeKey).sort();
188
- const bEdges = (b.edges ?? []).map(edgeKey).sort();
189
- return eEdges.length === bEdges.length && eEdges.every((k, i) => k === bEdges[i]);
190
- }
191
- /** `<source>@<port>-><target>@<port>`: an edge's identity apart from its id. */
192
- const edgeKey = (x) => `${x.sourceNodeId}@${x.sourcePort}->${x.targetNodeId}@${x.targetPort}`;
193
- /** A scope's layout with its `edges` re-keyed by endpoints, so an id shift does not read as a change. */
194
- function layoutByEndpoints(sf) {
195
- const layout = sf.layout;
196
- if (!layout || typeof layout !== 'object')
197
- return layout ?? null;
198
- const byId = new Map((sf.edges ?? []).map((x) => [x.id, edgeKey(x)]));
199
- const { edges, ...rest } = layout;
200
- const routes = Object.entries(edges ?? {}).map(([id, route]) => [byId.get(id) ?? id, route]).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
201
- return { ...rest, edges: Object.fromEntries(routes) };
202
- }
203
- /**
204
- * Whether merge restores `e`'s ORIGINAL node instead of taking `e`: there is a
205
- * baseline, the original has the node, and `e` equals its baseline counterpart.
206
- * {@link mergeFlow}'s node loop and {@link nodesTakenFromEdited} both ask this.
207
- */
208
- function isKept(e, origNodes, baseNodes, baseline) {
209
- return !!baseline && origNodes.has(e.id) && nodeUnchanged(e, baseNodes.get(e.id));
210
- }
211
100
  /**
212
101
  * Give an edited node the original's position, by id.
213
102
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uipath/maestro-builder-sdk",
3
- "version": "6.58.0",
3
+ "version": "6.58.1",
4
4
  "description": "Build UiPath Flow, Case, and BPMN artifacts by writing TypeScript.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://docs.uipath.com/maestro",
@@ -96,5 +96,5 @@
96
96
  "@types/node": "^22.7.0",
97
97
  "esbuild": "^0.28.1"
98
98
  },
99
- "gitref": "d70ce10e11def3cbb2d1d42ba52c76686dcfae2f"
99
+ "gitref": "5109abb1e51b0990a2318bf03312a805eb194fbf"
100
100
  }