@uipath/maestro-builder-sdk 6.61.1 → 6.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/check-cli.js CHANGED
@@ -2,19 +2,31 @@
2
2
  /**
3
3
  * check-cli — static-validate an authored flow without emitting.
4
4
  *
5
- * Usage: node --experimental-strip-types check-cli.js <flow.ts | BaseName>
5
+ * Usage: node --experimental-strip-types check-cli.js [--baseline <original.flow>] <flow.ts | BaseName>
6
6
  * Prints diagnostics; exits non-zero if any errors.
7
+ *
8
+ * `--baseline <original.flow>` scopes the findings to the edit
9
+ * (docs/CHECK_COMPILE_CONTRACT_DESIGN.md §3.3-3.4): the original is decompiled
10
+ * and checked in memory the same way, and each finding is marked `(edited)` or
11
+ * `(pre-existing)`. A pre-existing finding (its node unchanged, and the
12
+ * original reports it too) is a warning, so only the edit's errors fail the
13
+ * check; a pre-existing lowering one is printed as a probable SDK defect.
14
+ * `--package <name>` trusts a node-type package the original names, as
15
+ * `flow merge` and `flow decompile` take it.
7
16
  */
8
- import { diagnosticLine, reportDiagnostics } from './cli-result.js';
17
+ import { existsSync, readFileSync } from 'node:fs';
18
+ import { diagnosticLine, reportDiagnostics, reportResult } from './cli-result.js';
9
19
  import { defaultBindingsFile } from './workdir.js';
10
20
  import { resolveFlowFile, loadBuiltFlow } from './load.js';
11
- import { check } from './check.js';
21
+ import { check, checkCompiled, scopeToEdit } from './check.js';
12
22
  import { flowCheckOpts, openCliLibrary } from './cli-library.js';
13
23
  import { Bindings } from './bindings.js';
14
- import { runWhenInvokedDirectly } from './cli-run.js';
24
+ import { flagValue, flagValues, runWhenInvokedDirectly } from './cli-run.js';
15
25
  import { ensureTypeScriptRuntime } from './node-runtime.js';
26
+ import { withDecompiledOriginal } from './merge-compile.js';
27
+ import { loadNodePackages } from './node-packages.js';
16
28
  /** Flags that take a value, so the value is never read as the source argument. */
17
- const VALUE_FLAGS = new Set(['--library', '--bindings', '--connectors-local']);
29
+ const VALUE_FLAGS = new Set(['--library', '--bindings', '--connectors-local', '--baseline', '--package']);
18
30
  /**
19
31
  * Library lookups for the checks whose facts live in the connector corpus:
20
32
  * event parameters and measured list envelopes, and the library `check`'s
@@ -41,13 +53,21 @@ function checkOpts(argv) {
41
53
  export async function run(argv) {
42
54
  const arg = argv.find((a, i) => !a.startsWith('--') && !VALUE_FLAGS.has(argv[i - 1])) ?? argv[0];
43
55
  if (!arg || arg === '-h' || arg === '--help') {
44
- console.error('usage: check [--library <dir>] [--connectors-local <dir>] [--bindings <file>] <flow.ts | BaseName>');
56
+ console.error('usage: check [--library <dir>] [--connectors-local <dir>] [--bindings <file>] [--baseline <original.flow> [--package <name>]] <flow.ts | BaseName>');
57
+ return 2;
58
+ }
59
+ const baselinePath = flagValue(argv, '--baseline');
60
+ const original = baselinePath !== undefined ? readBaseline(baselinePath) : undefined;
61
+ if (original instanceof Error) {
62
+ console.error(`check: ${original.message}`);
63
+ reportResult({ error: original.message });
45
64
  return 2;
46
65
  }
47
66
  const file = resolveFlowFile(arg);
48
67
  ensureTypeScriptRuntime();
49
68
  const built = await loadBuiltFlow(file);
50
- const diags = check(built, checkOpts(argv));
69
+ const opts = checkOpts(argv);
70
+ const diags = baselinePath !== undefined ? await checkAgainstBaseline(built, baselinePath, original, opts, flagValues(argv, '--package')) : check(built, opts);
51
71
  reportDiagnostics(diags);
52
72
  if (diags.length === 0) {
53
73
  console.log('✓ no issues');
@@ -56,7 +76,58 @@ export async function run(argv) {
56
76
  for (const d of diags)
57
77
  console.log(diagnosticLine(d));
58
78
  const errors = diags.filter((d) => d.level === 'error').length;
59
- console.log(`\n${errors} error(s), ${diags.length - errors} warning(s)`);
79
+ console.log(`\n${baselinePath !== undefined ? baselineSummary(diags) : `${errors} error(s), ${diags.length - errors} warning(s)`}`);
60
80
  return errors ? 1 : 0;
61
81
  }
82
+ /**
83
+ * Check `built` and scope its findings to the edit ({@link scopeToEdit}). The
84
+ * baseline is the original decompiled and checked with the same options, as
85
+ * `flow merge --baseline-from` builds its baseline: first the node-type
86
+ * packages the original names are loaded, under the project's trust (or
87
+ * `--package`), so its nodes decompile to the same calls the edited source has.
88
+ */
89
+ async function checkAgainstBaseline(built, originalPath, original, opts, packages) {
90
+ const loaded = await loadNodePackages(originalPath, original, packages);
91
+ for (const message of loaded.warnings)
92
+ console.error(`check: warning: ${message}`);
93
+ const baseline = await withDecompiledOriginal(originalPath, (b) => checkCompiled(b, opts), (m) => console.error(`check: decompile warning: ${m}`));
94
+ return scopeToEdit(checkCompiled(built, opts), baseline);
95
+ }
96
+ /**
97
+ * The `--baseline` file, parsed, or the usage error to refuse it with: a path
98
+ * that does not exist, or a file that is not a `.flow` (JSON with `nodes`),
99
+ * such as the `.flow.ts` source passed by mistake. Refused before anything is
100
+ * loaded, with the usage exit code, so a bad flag never reads as a failed check.
101
+ */
102
+ function readBaseline(path) {
103
+ const usage = `--baseline must be the original .flow (JSON) the source was decompiled from: ${path}`;
104
+ if (!existsSync(path))
105
+ return new Error(`${usage} (no such file)`);
106
+ let parsed;
107
+ try {
108
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
109
+ }
110
+ catch (error) {
111
+ return new Error(`${usage} (not JSON: ${error instanceof Error ? error.message : String(error)})`);
112
+ }
113
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.nodes)) {
114
+ return new Error(`${usage} (no "nodes" array)`);
115
+ }
116
+ return parsed;
117
+ }
118
+ /**
119
+ * The `--baseline` summary: the errors that fail the check (the edit's), the
120
+ * pre-existing lowering findings (probable SDK defects) and the other
121
+ * pre-existing findings, and the edit's own warnings when there are any. For
122
+ * instance `1 error(s) in edited nodes, 1 pre-existing SDK defect(s), 1 pre-existing warning(s)`.
123
+ */
124
+ function baselineSummary(diags) {
125
+ const edited = diags.filter((d) => d.origin !== 'pre-existing');
126
+ const pre = diags.filter((d) => d.origin === 'pre-existing');
127
+ const errors = edited.filter((d) => d.level === 'error').length;
128
+ const defects = pre.filter((d) => d.class === 'lowering').length;
129
+ const warnings = edited.length - errors;
130
+ return `${errors} error(s) in edited nodes, ${defects} pre-existing SDK defect(s), ${pre.length - defects} pre-existing warning(s)`
131
+ + (warnings ? `, ${warnings} warning(s) in edited nodes` : '');
132
+ }
62
133
  runWhenInvokedDirectly(import.meta.url, 'check', run);
package/dist/check.d.ts CHANGED
@@ -3,12 +3,34 @@ import type { BuiltFlow } from './flow-sdk.js';
3
3
  import { type EventCheckOpts } from './core/event-checks.js';
4
4
  export type { EventParamFields } from './core/event-checks.js';
5
5
  import { type ConnectorCheckOpts } from './core/connector-checks.js';
6
+ import { type RefusalClass } from './core/refusals.js';
6
7
  export interface Diagnostic {
7
8
  level: 'error' | 'warning';
8
9
  code: string;
9
10
  message: string;
10
11
  step?: string;
11
12
  suggestion?: string;
13
+ /**
14
+ * The id of the node the finding is on, in the flow `compile` would write:
15
+ * the step's node, or for a step inside a subflow the node inside it.
16
+ * Absent for a finding that names no node (a flow-level rule, a refusal
17
+ * about the whole scope such as two steps with one name).
18
+ */
19
+ nodeId?: string;
20
+ /**
21
+ * The code's class (`core/refusals.ts`): `lint`, the flow is probably wrong
22
+ * but `compile` would still write it; `lowering`, `compile` cannot write a
23
+ * correct `.flow`; `draft`, an unfinished canvas node. Set on every finding
24
+ * whose code the table lists.
25
+ */
26
+ class?: RefusalClass;
27
+ /**
28
+ * Only when `check` is given a baseline (`flow check --baseline`, {@link scopeToEdit}):
29
+ * `pre-existing` when the finding's node is unchanged from the baseline and
30
+ * the baseline reports the same code on it, so the edit did not cause it
31
+ * (such a finding is a warning); `edited` for every other finding.
32
+ */
33
+ origin?: 'edited' | 'pre-existing';
12
34
  }
13
35
  export interface ListEnvelopeFields {
14
36
  envelope: string | null;
@@ -47,6 +69,64 @@ export interface CheckOpts extends ConnectorCheckOpts, EventCheckOpts {
47
69
  serializer?: false;
48
70
  }
49
71
  export declare function check(built: BuiltFlow, opts?: CheckOpts): Diagnostic[];
72
+ /**
73
+ * What {@link checkCompiled} found, with the flow its serializer pass compiled,
74
+ * which {@link scopeToEdit} compares against a baseline.
75
+ */
76
+ export interface CheckedFlow {
77
+ diagnostics: Diagnostic[];
78
+ /**
79
+ * The collect-mode compile, stand-ins and all (`SerializeOpts.collect`);
80
+ * nothing writes it. `undefined` when collection stopped or the serializer
81
+ * pass was skipped.
82
+ */
83
+ flow: any;
84
+ /**
85
+ * The refusal that stopped collection (a scope-level one, such as two Ends
86
+ * with one name), or the internal error that did: no serializer finding past
87
+ * it is reported. Absent when collection ran to the end or was skipped.
88
+ */
89
+ stoppedAt?: {
90
+ code?: string;
91
+ message: string;
92
+ };
93
+ }
94
+ /**
95
+ * `check`, keeping the flow its serializer pass compiled. Each finding is
96
+ * placed on its node (`nodeId`: a refusal on the node collect mode claimed for
97
+ * its step, any other finding on the node its step emitted) and given its
98
+ * code's `class`.
99
+ */
100
+ export declare function checkCompiled(built: BuiltFlow, opts?: CheckOpts): CheckedFlow;
101
+ /**
102
+ * Scope `edited`'s findings to the edit, against `baseline`: the original
103
+ * `.flow` decompiled and checked the same way (`flow check --baseline`,
104
+ * docs/CHECK_COMPILE_CONTRACT_DESIGN.md §3.3). Every finding gets an `origin`:
105
+ *
106
+ * - `pre-existing` when its node is unchanged from the baseline (by id and
107
+ * content, as merge judges it: `classifyDrafts`; a node inside a subflow
108
+ * by its subflow) AND the baseline reports the same code on that node,
109
+ * once per occurrence. A finding that names no node is `pre-existing` when
110
+ * the baseline reports the same code and message (`splitUnplaced`).
111
+ * - `edited` otherwise, including a finding on an untouched node that the
112
+ * baseline does not have: an edit elsewhere caused it (a deleted variable
113
+ * the node updates, a deleted step it reads).
114
+ *
115
+ * A pre-existing error becomes a warning, so it does not fail the check. A
116
+ * pre-existing `lowering` one also says it is probably an SDK defect, since the
117
+ * designer's node is one the SDK cannot write back: `compile` still refuses it,
118
+ * and `merge --baseline-from` keeps it as saved.
119
+ *
120
+ * When the edited flow's collection stopped at a scope-level refusal, the rest
121
+ * of the flow was never judged, so one more error, CHECK_INCOMPLETE (class
122
+ * `lowering`: `compile` stops at the refusal it names), says so:
123
+ * passing never means "not checked".
124
+ *
125
+ * For instance, an `EMPTY_ARM` on a branch the edit did not touch, which the
126
+ * baseline reports too, comes back as a `pre-existing` warning; the same
127
+ * finding after the edit changed that branch's condition is an `edited` error.
128
+ */
129
+ export declare function scopeToEdit(edited: CheckedFlow, baseline: CheckedFlow): Diagnostic[];
50
130
  /**
51
131
  * The ISO-8601 repeating-interval pattern, copied VERBATIM from the
52
132
  * `core.trigger.scheduled@1.1` definition (`inputDefinition.then.properties.
package/dist/check.js CHANGED
@@ -30,7 +30,18 @@ import { stepDeclaredVariables } from './flow-step-variables.js';
30
30
  import { plainHttpOnErrorMessage } from './nodes/http/flow/family.js';
31
31
  import { resolveVersion, resolveVersionCarried } from './core/nodes/versioned.js';
32
32
  import { loopParallelWarning, resolveLoopDefinition, loopVersion } from './loop-version.js';
33
+ import { refusalClass } from './core/refusals.js';
34
+ import { classifyDrafts, findingCounts, placeDiagnostics, splitUnplaced } from './edit-scope.js';
33
35
  export function check(built, opts = {}) {
36
+ return checkCompiled(built, opts).diagnostics;
37
+ }
38
+ /**
39
+ * `check`, keeping the flow its serializer pass compiled. Each finding is
40
+ * placed on its node (`nodeId`: a refusal on the node collect mode claimed for
41
+ * its step, any other finding on the node its step emitted) and given its
42
+ * code's `class`.
43
+ */
44
+ export function checkCompiled(built, opts = {}) {
34
45
  // A connection two connectors use, and an agent resource's `nodeId` another
35
46
  // node of the file has, are the serializer's CONNECTION_CONNECTOR_CONFLICT /
36
47
  // INLINE_AGENT_RESOURCE_NODE_ID: it claims ids and resolves connections file-wide.
@@ -44,10 +55,109 @@ export function check(built, opts = {}) {
44
55
  ...(d.suggestion ? { suggestion: d.suggestion } : {}),
45
56
  });
46
57
  }
47
- if (opts.serializer !== false)
48
- diags.push(...serializerRefusals(built, { library: opts.library, bindings: opts.bindings }, diags));
49
- return diags;
58
+ const serialized = opts.serializer !== false
59
+ ? serializerRefusals(built, { library: opts.library, bindings: opts.bindings }, diags)
60
+ : { diagnostics: [], flow: undefined };
61
+ diags.push(...serialized.diagnostics);
62
+ const placed = serialized.flow !== undefined ? placeDiagnostics(diags, serialized.flow) : undefined;
63
+ const diagnostics = diags.map((d, i) => {
64
+ const nodeId = d.nodeId ?? (placed?.[i].nodeId || undefined);
65
+ const cls = refusalClass(d.code);
66
+ return { ...d, ...(nodeId !== undefined ? { nodeId } : {}), ...(cls !== undefined ? { class: cls } : {}) };
67
+ });
68
+ return { diagnostics, flow: serialized.flow, ...(serialized.stoppedAt ? { stoppedAt: serialized.stoppedAt } : {}) };
69
+ }
70
+ /**
71
+ * Scope `edited`'s findings to the edit, against `baseline`: the original
72
+ * `.flow` decompiled and checked the same way (`flow check --baseline`,
73
+ * docs/CHECK_COMPILE_CONTRACT_DESIGN.md §3.3). Every finding gets an `origin`:
74
+ *
75
+ * - `pre-existing` when its node is unchanged from the baseline (by id and
76
+ * content, as merge judges it: `classifyDrafts`; a node inside a subflow
77
+ * by its subflow) AND the baseline reports the same code on that node,
78
+ * once per occurrence. A finding that names no node is `pre-existing` when
79
+ * the baseline reports the same code and message (`splitUnplaced`).
80
+ * - `edited` otherwise, including a finding on an untouched node that the
81
+ * baseline does not have: an edit elsewhere caused it (a deleted variable
82
+ * the node updates, a deleted step it reads).
83
+ *
84
+ * A pre-existing error becomes a warning, so it does not fail the check. A
85
+ * pre-existing `lowering` one also says it is probably an SDK defect, since the
86
+ * designer's node is one the SDK cannot write back: `compile` still refuses it,
87
+ * and `merge --baseline-from` keeps it as saved.
88
+ *
89
+ * When the edited flow's collection stopped at a scope-level refusal, the rest
90
+ * of the flow was never judged, so one more error, CHECK_INCOMPLETE (class
91
+ * `lowering`: `compile` stops at the refusal it names), says so:
92
+ * passing never means "not checked".
93
+ *
94
+ * For instance, an `EMPTY_ARM` on a branch the edit did not touch, which the
95
+ * baseline reports too, comes back as a `pre-existing` warning; the same
96
+ * finding after the edit changed that branch's condition is an `edited` error.
97
+ */
98
+ export function scopeToEdit(edited, baseline) {
99
+ const locate = (c) => placeDiagnostics(c.diagnostics, c.flow)
100
+ .map((d, i) => ({ ...d, nodeId: c.diagnostics[i].nodeId ?? d.nodeId, index: i }));
101
+ const sorted = classifyDrafts(baseline.flow ?? {}, edited.flow ?? {}, baseline.flow, locate(edited));
102
+ // The baseline's findings on each node, by code; each one vouches for one edited finding.
103
+ const onNode = (f) => `${f.nodeId}\n${f.code}`;
104
+ const pool = new Map();
105
+ for (const b of locate(baseline)) {
106
+ if (b.nodeId === '')
107
+ continue;
108
+ pool.set(onNode(b), [...(pool.get(onNode(b)) ?? []), b.message]);
109
+ }
110
+ const preExisting = new Set();
111
+ // Exact messages are matched first: of two findings of one code on a node,
112
+ // the one the baseline has word for word is the pre-existing one. The second
113
+ // pass forgives wording the edit can shift without touching the finding (a
114
+ // "Known steps: ..." list), but only between messages that name the same
115
+ // quoted subjects: `UNKNOWN_REF "lookup" in "audit"` is a different finding
116
+ // from the baseline's `UNKNOWN_REF "ghost" in "audit"`, so it stays edited.
117
+ for (const exact of [true, false]) {
118
+ for (const f of sorted.kept) {
119
+ if (preExisting.has(f.index))
120
+ continue;
121
+ const messages = pool.get(onNode(f)) ?? [];
122
+ const j = exact ? messages.indexOf(f.message) : messages.findIndex((m) => quotedSubjects(m) === quotedSubjects(f.message));
123
+ if (j < 0)
124
+ continue;
125
+ messages.splice(j, 1);
126
+ preExisting.add(f.index);
127
+ }
128
+ }
129
+ for (const f of splitUnplaced(sorted.unplaced, findingCounts(baseline.diagnostics)).known)
130
+ preExisting.add(f.index);
131
+ const out = edited.diagnostics.map((d, i) => {
132
+ if (!preExisting.has(i))
133
+ return { ...d, origin: 'edited' };
134
+ const defect = d.level === 'error' && d.class === 'lowering';
135
+ return {
136
+ ...d,
137
+ level: 'warning',
138
+ message: defect ? `${d.message}\n ${PRE_EXISTING_LOWERING_NOTE}` : d.message,
139
+ origin: 'pre-existing',
140
+ };
141
+ });
142
+ if (edited.stoppedAt) {
143
+ const at = edited.stoppedAt.code !== undefined ? `[${edited.stoppedAt.code}] ` : '';
144
+ out.push({
145
+ level: 'error', code: 'CHECK_INCOMPLETE',
146
+ message: `check stopped at ${at}${edited.stoppedAt.message} Nothing after that refusal was checked, so the edit is not judged. `
147
+ + 'Fix it, even if the original has it too, and run check again.',
148
+ class: refusalClass('CHECK_INCOMPLETE'), origin: 'edited',
149
+ });
150
+ }
151
+ return out;
152
+ }
153
+ /** The `"quoted"` names in a finding's message, in order: what it is about, apart from its wording. */
154
+ function quotedSubjects(message) {
155
+ return JSON.stringify(message.match(/"[^"]*"/g) ?? []);
50
156
  }
157
+ /** What a pre-existing lowering finding adds to its message ({@link scopeToEdit}). */
158
+ const PRE_EXISTING_LOWERING_NOTE = 'This node is unchanged from the baseline, so this is probably an SDK defect: '
159
+ + 'the SDK cannot read what the designer wrote. `compile` refuses this node; `merge --baseline-from` keeps it as saved. '
160
+ + 'Report it: https://github.com/UiPath/builder-sdk/issues';
51
161
  /** The error-level diagnostics a custom node's own `diagnostics()` reported (see `serializerRefusals`). */
52
162
  const OWN_NODE_ERRORS = new WeakSet();
53
163
  /**
@@ -67,13 +177,20 @@ const OWN_NODE_ERRORS = new WeakSet();
67
177
  */
68
178
  function serializerRefusals(built, compile, found) {
69
179
  const collected = [];
180
+ let flow;
181
+ let stoppedAt;
70
182
  try {
71
- serialize(built, { ...compile, collect: (r) => collected.push(r) });
183
+ flow = serialize(built, { ...compile, collect: (r) => collected.push(r) });
184
+ // Collection stopped: the refusal that stopped it is the last one reported.
185
+ const stop = collected[collected.length - 1];
186
+ if (flow === undefined && stop)
187
+ stoppedAt = { code: stop.code, message: stop.message };
72
188
  }
73
189
  catch (error) {
74
190
  // LIBRARY_REQUIRED is dropped below, so it does not count as something reported.
75
191
  if (!found.some((d) => d.level === 'error') && !collected.some((r) => r.code !== 'LIBRARY_REQUIRED'))
76
192
  throw error;
193
+ stoppedAt = { message: error instanceof Error ? error.message : String(error) };
77
194
  }
78
195
  const out = [];
79
196
  const seen = new Set();
@@ -100,9 +217,10 @@ function serializerRefusals(built, compile, found) {
100
217
  level: 'error', code: r.code, message,
101
218
  ...(step !== undefined ? { step } : {}),
102
219
  ...(r.suggestion !== undefined ? { suggestion: r.suggestion } : {}),
220
+ ...(r.nodeId !== undefined ? { nodeId: r.nodeId } : {}),
103
221
  });
104
222
  }
105
- return out;
223
+ return { diagnostics: out, flow, ...(stoppedAt ? { stoppedAt } : {}) };
106
224
  }
107
225
  /**
108
226
  * `ancestors` is the chain of child flows currently being checked, innermost
@@ -40,7 +40,10 @@ export function registerFlowCommands(program) {
40
40
  .option('--source', 'check authored TypeScript')
41
41
  .addOption(retiredCompiledOption())
42
42
  .option('--library <dir>', 'connector library directory')
43
- .option('--connectors-local <dir>', 'connection-resolved connector overlay');
43
+ .option('--connectors-local <dir>', 'connection-resolved connector overlay')
44
+ .option('--bindings <file>', 'bindings.json path')
45
+ .option('--baseline <file>', 'the original .flow the source was decompiled from (what merge --baseline-from takes, not the compiled baseline merge --baseline takes): findings on nodes the edit did not touch, which the original has too, become warnings')
46
+ .option('--package <name...>', 'trust and load this node-type package for --baseline (repeatable)');
44
47
  // Hidden, not removed: scripts that pass these keep parsing.
45
48
  for (const [flags, , description] of RETIRED_COMPILED_CHECK_FLAGS) {
46
49
  check.addOption(new Option(flags, description).hideHelp());
@@ -69,6 +72,10 @@ export function registerFlowCommands(program) {
69
72
  const argv = [input];
70
73
  addValue(argv, '--library', options.library);
71
74
  addValue(argv, '--connectors-local', options.connectorsLocal);
75
+ addValue(argv, '--bindings', options.bindings);
76
+ addValue(argv, '--baseline', options.baseline);
77
+ for (const name of options.package ?? [])
78
+ argv.push('--package', name);
72
79
  await invoke('flow check', () => import('../../check-cli.js'), argv);
73
80
  return;
74
81
  }
@@ -24,30 +24,34 @@ export declare function reportResult(fields: Record<string, unknown>): void;
24
24
  * @param text - the result as text; a trailing newline is added.
25
25
  */
26
26
  export declare function printResult(text: string): void;
27
- /**
28
- * Report a check's diagnostics as `{ errors, warnings, diagnostics }`, the one
29
- * shape every family's check (and compile's check pass) uses.
30
- */
31
- export declare function reportDiagnostics(diags: ReadonlyArray<{
27
+ /** A diagnostic as `reportDiagnostics` and `diagnosticLine` read it: any family's, with the fields it has. */
28
+ interface ReportedDiagnostic {
32
29
  level: string;
33
30
  code: string;
34
31
  message: string;
35
32
  step?: string;
36
33
  suggestion?: string;
37
- }>): void;
34
+ nodeId?: string;
35
+ class?: string;
36
+ origin?: string;
37
+ }
38
+ /**
39
+ * Report a check's diagnostics as `{ errors, warnings, diagnostics }`, the one
40
+ * shape every family's check (and compile's check pass) uses. Each diagnostic
41
+ * keeps the optional fields it has: `step`, `suggestion`, and Flow's `nodeId`,
42
+ * `class` and (with `check --baseline`) `origin`.
43
+ */
44
+ export declare function reportDiagnostics(diags: ReadonlyArray<ReportedDiagnostic>): void;
38
45
  /**
39
46
  * One diagnostic as `check` and `compile` print it: its mark and code, the
40
- * message, the step it is on (unless the message already names it), and the
41
- * suggestion. For instance
42
- * `✗ [STEP_SWITCH_EXIT_UNKNOWN] no exit named "maybe". … (step "route")`.
47
+ * message, the step it is on (unless the message already names it), the
48
+ * suggestion, and, with `check --baseline`, its origin. For instance
49
+ * `✗ [STEP_SWITCH_EXIT_UNKNOWN] no exit named "maybe". … (step "route")`, or
50
+ * `⚠ [EMPTY_ARM] … (pre-existing, not counted)`. With an origin, a message's
51
+ * later lines (the note on a pre-existing lowering finding) follow the line
52
+ * the origin ends.
43
53
  */
44
- export declare function diagnosticLine(d: {
45
- level: string;
46
- code: string;
47
- message: string;
48
- step?: string;
49
- suggestion?: string;
50
- }): string;
54
+ export declare function diagnosticLine(d: ReportedDiagnostic): string;
51
55
  /**
52
56
  * The error diagnostic for a throw that ends a compile: the error's own string
53
57
  * `code` when it carries one, else `COMPILE_FAILED`.
@@ -57,3 +61,4 @@ export declare function thrownDiagnostic(error: unknown): {
57
61
  code: string;
58
62
  message: string;
59
63
  };
64
+ export {};
@@ -38,7 +38,9 @@ export function printResult(text) {
38
38
  }
39
39
  /**
40
40
  * Report a check's diagnostics as `{ errors, warnings, diagnostics }`, the one
41
- * shape every family's check (and compile's check pass) uses.
41
+ * shape every family's check (and compile's check pass) uses. Each diagnostic
42
+ * keeps the optional fields it has: `step`, `suggestion`, and Flow's `nodeId`,
43
+ * `class` and (with `check --baseline`) `origin`.
42
44
  */
43
45
  export function reportDiagnostics(diags) {
44
46
  reportResult({
@@ -48,21 +50,34 @@ export function reportDiagnostics(diags) {
48
50
  level: d.level, code: d.code, message: d.message,
49
51
  ...(d.step !== undefined ? { step: d.step } : {}),
50
52
  ...(d.suggestion ? { suggestion: d.suggestion } : {}),
53
+ ...(d.nodeId !== undefined ? { nodeId: d.nodeId } : {}),
54
+ ...(d.class !== undefined ? { class: d.class } : {}),
55
+ ...(d.origin !== undefined ? { origin: d.origin } : {}),
51
56
  })),
52
57
  });
53
58
  }
54
59
  /**
55
60
  * One diagnostic as `check` and `compile` print it: its mark and code, the
56
- * message, the step it is on (unless the message already names it), and the
57
- * suggestion. For instance
58
- * `✗ [STEP_SWITCH_EXIT_UNKNOWN] no exit named "maybe". … (step "route")`.
61
+ * message, the step it is on (unless the message already names it), the
62
+ * suggestion, and, with `check --baseline`, its origin. For instance
63
+ * `✗ [STEP_SWITCH_EXIT_UNKNOWN] no exit named "maybe". … (step "route")`, or
64
+ * `⚠ [EMPTY_ARM] … (pre-existing, not counted)`. With an origin, a message's
65
+ * later lines (the note on a pre-existing lowering finding) follow the line
66
+ * the origin ends.
59
67
  */
60
68
  export function diagnosticLine(d) {
61
69
  // A step inside a subflow is `<caller> › <step>`; the message names the innermost one.
62
70
  const named = d.step !== undefined && d.message.includes(`"${d.step.split(' › ').pop()}"`);
63
- return `${d.level === 'error' ? '✗' : '⚠'} [${d.code}] ${d.message}`
64
- + (d.step !== undefined && !named ? ` (step "${d.step}")` : '')
71
+ const suffix = (d.step !== undefined && !named ? ` (step "${d.step}")` : '')
65
72
  + (d.suggestion ? ` (did you mean "${d.suggestion}"?)` : '');
73
+ const mark = `${d.level === 'error' ? '✗' : '⚠'} [${d.code}]`;
74
+ if (d.origin === undefined)
75
+ return `${mark} ${d.message}${suffix}`;
76
+ // With a baseline the origin ends the finding's first line, where it is seen.
77
+ const origin = d.origin === 'edited' ? ' (edited)'
78
+ : d.class === 'lowering' ? ' (pre-existing)' : ' (pre-existing, not counted)';
79
+ const [first, ...rest] = d.message.split('\n');
80
+ return [`${mark} ${first}${suffix}${origin}`, ...rest].join('\n');
66
81
  }
67
82
  /**
68
83
  * The error diagnostic for a throw that ends a compile: the error's own string
@@ -55,6 +55,7 @@ export declare const REFUSALS: {
55
55
  readonly CARRIED_DEFINITION_CONFLICT: "lowering";
56
56
  readonly CARRIED_DEFINITION_INVALID: "lowering";
57
57
  readonly CEQL_UNPARSEABLE: "lowering";
58
+ readonly CHECK_INCOMPLETE: "lowering";
58
59
  readonly COMPILE_FAILED: "lowering";
59
60
  readonly CONNECTION_CONNECTOR_CONFLICT: "draft";
60
61
  readonly CONNECTION_STUB: "lint";
@@ -93,6 +93,9 @@ export const REFUSALS = {
93
93
  CARRIED_DEFINITION_CONFLICT: 'lowering',
94
94
  CARRIED_DEFINITION_INVALID: 'lowering',
95
95
  CEQL_UNPARSEABLE: 'lowering',
96
+ // Only `check --baseline` reports it: collection stopped at a scope-level
97
+ // refusal, and `compile` (and `merge --baseline-from`) stops at that refusal.
98
+ CHECK_INCOMPLETE: 'lowering',
96
99
  // A throw that is no refusal, which `compile` cannot get past: check reports it on its step.
97
100
  COMPILE_FAILED: 'lowering',
98
101
  CONNECTION_CONNECTOR_CONFLICT: 'draft',
@@ -12,6 +12,9 @@
12
12
  *
13
13
  * For instance, a canvas Slack node with no connection yet that the author did
14
14
  * not touch is `kept`; the same node with a new message text is `refused`.
15
+ * A node the SDK cannot lower is compared by the stand-ins the two collect-mode
16
+ * compiles emit for it ({@link isStandIn}), and kept only when the original's
17
+ * own round trip refuses it too ({@link classifyLowering}).
15
18
  *
16
19
  * Pure: no file access, so a portable entry (`check.ts`, `merge.ts`) can use
17
20
  * it. Reading the original and compiling its baseline is merge-compile.ts's.
@@ -70,6 +73,13 @@ export declare function byId(nodes: AnyNode[] | undefined): Map<string, AnyNode>
70
73
  * decide "did the user edit this node?" against the baseline.
71
74
  */
72
75
  export declare function nodeUnchanged(a: AnyNode | undefined, b: AnyNode | undefined): boolean;
76
+ /**
77
+ * Whether `n` is the opaque node a collect-mode compile emits in place of a
78
+ * step it could not lower (serialize.ts `standInNode`): the node type, the
79
+ * step's `{ version }`, and `inputs: { standIn: true, spec }`, the spec as
80
+ * authored. It is never written: merge restores the original node or refuses.
81
+ */
82
+ export declare function isStandIn(n: AnyNode | undefined): boolean;
73
83
  /**
74
84
  * The keys of the subflows `mergeFlow` (merge.ts) takes from `edited` rather than
75
85
  * restoring from `original`: every subflow that is new, that the original does
@@ -96,9 +106,15 @@ export declare function isKept(e: AnyNode, origNodes: Map<string, AnyNode>, base
96
106
  * Why a finding is an error: `edited`, its node is one the author changed or
97
107
  * added; `subflow`, it sits in a subflow the edit changed, which merge takes
98
108
  * whole; `unplaced`, it names no node and the original's own round trip does
99
- * not report it, so the edit introduced it.
109
+ * not report it, so the edit introduced it. Two more are for a lowering
110
+ * refusal on a node merge would keep ({@link classifyLowering}):
111
+ * `introduced`, the original's own round trip does not report it on that node,
112
+ * so an edit elsewhere broke it; `unrestorable`, restoring the node from the
113
+ * original would not undo it; `uncompared`, the node is the original's, but
114
+ * there is no collect-mode compile of the original to compare its stand-in
115
+ * with (no `--baseline-from`), so merge cannot tell whether the edit touched it.
100
116
  */
101
- export type TakenReason = 'edited' | 'subflow' | 'unplaced';
117
+ export type TakenReason = 'edited' | 'subflow' | 'unplaced' | 'introduced' | 'unrestorable' | 'uncompared';
102
118
  /**
103
119
  * A finding on a node merge takes from the edited flow, with the reason it
104
120
  * takes it: `edited`, the author changed or added the node; `subflow`, the
@@ -135,6 +151,47 @@ export declare function classifyDrafts<T extends {
135
151
  refused: Refused<T>[];
136
152
  unplaced: T[];
137
153
  };
154
+ /** A refusal a compile reported on the node `nodeId` (`''` when it names none). */
155
+ export interface NodeRefusal {
156
+ nodeId: string;
157
+ code: string;
158
+ message: string;
159
+ }
160
+ /**
161
+ * Sort the lowering refusals of a collect-mode compile of the edited flow
162
+ * (`SerializeOpts.collect`, design D3) by what merge does with their node, as
163
+ * {@link classifyDrafts} does for drafts, then hold a kept one to the rest of
164
+ * what "pre-existing" means (docs/CHECK_COMPILE_CONTRACT_DESIGN.md §3.3). It
165
+ * stays `kept`, and merge restores the original node, only when:
166
+ *
167
+ * - the ORIGINAL's own round trip (`baselineRefusals`, the baseline compiled
168
+ * in collect mode) reports the same code on the same node; else it is
169
+ * refused as `introduced`: an edit elsewhere broke a node the author did
170
+ * not touch (a deleted node it reads), and restoring it would keep the
171
+ * break;
172
+ * - restoring the node undoes the refusal; else it is refused as
173
+ * `unrestorable`. That holds for a root node the compile stood in for
174
+ * ({@link isStandIn}: its own emission failed, and its edges are wired as
175
+ * any node's), and for any node of an untouched subflow, which merge
176
+ * restores whole. A refusal on a root node that did lower (an edge out of
177
+ * it, a join it feeds) is about edges merge takes from the edited flow.
178
+ * The {@link NEVER_KEPT_CODES} are `unrestorable` too.
179
+ *
180
+ * `baselineRefusals` is `undefined` when the baseline was not compiled in
181
+ * collect mode (a precompiled `--baseline` file, or none). Then a stand-in has
182
+ * nothing to equal (a precompiled baseline holds the node lowered, or the file
183
+ * would not exist), and nothing says how the original's round trip treats the
184
+ * node, so every refusal on a node the original has is `uncompared`, never
185
+ * `edited`: the author may not have touched it.
186
+ *
187
+ * For instance a canvas Jira node whose input the SDK cannot read, untouched,
188
+ * is `kept`; the same node with a changed summary is `refused` as `edited`.
189
+ */
190
+ export declare function classifyLowering<T extends NodeRefusal>(original: any, edited: any, baseline: any, refusals: readonly T[], baselineRefusals: readonly NodeRefusal[] | undefined): {
191
+ kept: Kept<T>[];
192
+ refused: Refused<T>[];
193
+ unplaced: T[];
194
+ };
138
195
  /**
139
196
  * A `check()` diagnostic, placed on the node its step emitted (`''` when it
140
197
  * names no node); `subflow` is the calling step when the finding is inside a
@@ -15,9 +15,25 @@ export function nodeUnchanged(a, b) {
15
15
  return false;
16
16
  if (a.type !== b.type)
17
17
  return false;
18
+ // A step that fails to lower has no compiled node on either side, only the
19
+ // collect-mode stand-in (D5): equal stand-ins mean the edit did not touch the
20
+ // spec. Its `typeVersion` is the author's `{ version }` pin, so it counts.
21
+ if (isStandIn(a) || isStandIn(b)) {
22
+ return isStandIn(a) && isStandIn(b) && a.typeVersion === b.typeVersion
23
+ && JSON.stringify(a.inputs) === JSON.stringify(b.inputs);
24
+ }
18
25
  return JSON.stringify(a.inputs ?? {}) === JSON.stringify(b.inputs ?? {})
19
26
  && JSON.stringify(outputSources(a)) === JSON.stringify(outputSources(b));
20
27
  }
28
+ /**
29
+ * Whether `n` is the opaque node a collect-mode compile emits in place of a
30
+ * step it could not lower (serialize.ts `standInNode`): the node type, the
31
+ * step's `{ version }`, and `inputs: { standIn: true, spec }`, the spec as
32
+ * authored. It is never written: merge restores the original node or refuses.
33
+ */
34
+ export function isStandIn(n) {
35
+ return n?.inputs?.standIn === true;
36
+ }
21
37
  /** Each output's `source` by output name; outputs without one are left out. */
22
38
  function outputSources(n) {
23
39
  const sources = {};
@@ -160,6 +176,72 @@ export function classifyDrafts(original, edited, baseline, findings) {
160
176
  }
161
177
  return { kept, refused, unplaced };
162
178
  }
179
+ /**
180
+ * Lowering codes merge never keeps as saved, even on an untouched node:
181
+ * `COMPILE_FAILED` is a throw the SDK has no refusal for (a crash, not a
182
+ * finding to step over), and `LIBRARY_REQUIRED` is about compile's inputs,
183
+ * not about the node.
184
+ */
185
+ const NEVER_KEPT_CODES = new Set(['COMPILE_FAILED', 'LIBRARY_REQUIRED']);
186
+ /**
187
+ * Sort the lowering refusals of a collect-mode compile of the edited flow
188
+ * (`SerializeOpts.collect`, design D3) by what merge does with their node, as
189
+ * {@link classifyDrafts} does for drafts, then hold a kept one to the rest of
190
+ * what "pre-existing" means (docs/CHECK_COMPILE_CONTRACT_DESIGN.md §3.3). It
191
+ * stays `kept`, and merge restores the original node, only when:
192
+ *
193
+ * - the ORIGINAL's own round trip (`baselineRefusals`, the baseline compiled
194
+ * in collect mode) reports the same code on the same node; else it is
195
+ * refused as `introduced`: an edit elsewhere broke a node the author did
196
+ * not touch (a deleted node it reads), and restoring it would keep the
197
+ * break;
198
+ * - restoring the node undoes the refusal; else it is refused as
199
+ * `unrestorable`. That holds for a root node the compile stood in for
200
+ * ({@link isStandIn}: its own emission failed, and its edges are wired as
201
+ * any node's), and for any node of an untouched subflow, which merge
202
+ * restores whole. A refusal on a root node that did lower (an edge out of
203
+ * it, a join it feeds) is about edges merge takes from the edited flow.
204
+ * The {@link NEVER_KEPT_CODES} are `unrestorable` too.
205
+ *
206
+ * `baselineRefusals` is `undefined` when the baseline was not compiled in
207
+ * collect mode (a precompiled `--baseline` file, or none). Then a stand-in has
208
+ * nothing to equal (a precompiled baseline holds the node lowered, or the file
209
+ * would not exist), and nothing says how the original's round trip treats the
210
+ * node, so every refusal on a node the original has is `uncompared`, never
211
+ * `edited`: the author may not have touched it.
212
+ *
213
+ * For instance a canvas Jira node whose input the SDK cannot read, untouched,
214
+ * is `kept`; the same node with a changed summary is `refused` as `edited`.
215
+ */
216
+ export function classifyLowering(original, edited, baseline, refusals, baselineRefusals) {
217
+ const sorted = classifyDrafts(original, edited, baseline, refusals);
218
+ if (baselineRefusals === undefined) {
219
+ const originalIds = new Set([
220
+ ...(original?.nodes ?? []).map((n) => n.id),
221
+ ...Object.values(original?.subflows ?? {}).flatMap((sf) => (sf?.nodes ?? []).map((n) => n.id)),
222
+ ]);
223
+ const refused = [...sorted.kept, ...sorted.refused].map((f) => ({
224
+ ...f,
225
+ reason: originalIds.has(f.nodeId) ? 'uncompared' : f.reason ?? 'edited',
226
+ }));
227
+ return { kept: [], refused, unplaced: sorted.unplaced };
228
+ }
229
+ const onNode = (r) => `${r.nodeId}\n${r.code}`;
230
+ const inBaseline = new Set(baselineRefusals.filter((r) => r.nodeId !== '').map(onNode));
231
+ const editedNodes = byId(edited?.nodes);
232
+ const kept = [];
233
+ const refused = [...sorted.refused];
234
+ for (const f of sorted.kept) {
235
+ const restorable = !NEVER_KEPT_CODES.has(f.code) && (f.subflow !== undefined || isStandIn(editedNodes.get(f.nodeId)));
236
+ if (!restorable)
237
+ refused.push({ ...f, reason: 'unrestorable' });
238
+ else if (!inBaseline.has(onNode(f)))
239
+ refused.push({ ...f, reason: 'introduced' });
240
+ else
241
+ kept.push(f);
242
+ }
243
+ return { kept, refused, unplaced: sorted.unplaced };
244
+ }
163
245
  /**
164
246
  * Place each `check()` diagnostic on the node its step emitted in `editedFlow`, so
165
247
  * {@link classifyDrafts} can sort it: a root step on its node (by label, then
package/dist/merge-cli.js CHANGED
@@ -9,8 +9,14 @@
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
13
- * and edit-scope.ts.
12
+ * only where it would take the node from the edited flow. Both also run in
13
+ * collect mode, so a node the SDK cannot lower (#1402) is stood in for: merge
14
+ * restores it from the original when the edit did not touch it and the
15
+ * original's own round trip refuses it too (`LOWERING_NODE_KEPT`, printed as a
16
+ * probable SDK defect), and refuses the write when the edit changed it. See
17
+ * merge-compile.ts and edit-scope.ts. Neither warning depends on `--strict`:
18
+ * both nodes are written as the canvas saved them; `--strict` still refuses a
19
+ * stand-in whose wiring changed the round trip.
14
20
  *
15
21
  * `edited.flow` is compile(decompile(original) after your edits). `--baseline`
16
22
  * is compile(decompile(original)) BEFORE any edit — supplying it enables the
@@ -57,7 +63,7 @@ import { Bindings } from './bindings.js';
57
63
  import { defaultBindingsFile } from './workdir.js';
58
64
  import { loadNodePackages } from './node-packages.js';
59
65
  import { baselineFromOriginal, checkEdited, compileSourceDraftTolerant } from './merge-compile.js';
60
- import { classifyDrafts, findingCounts, splitUnplaced } from './edit-scope.js';
66
+ import { classifyDrafts, classifyLowering, findingCounts, splitUnplaced } from './edit-scope.js';
61
67
  const USAGE = 'usage: flow-merge <original.flow> <edited.flow | Edited.flow.ts> [-o merged.flow] '
62
68
  + '[--baseline <baseline.flow> | --baseline-from <original.flow>] [--library <dir>] [--bindings <file>] [--strict]';
63
69
  /**
@@ -185,6 +191,7 @@ export async function run(argv) {
185
191
  const compileOpts = { library, bindings };
186
192
  const original = JSON.parse(readFileSync(originalPath, 'utf8'));
187
193
  let drafts = [];
194
+ let lowering = [];
188
195
  let diagnostics = [];
189
196
  let edited;
190
197
  let editedSidecars;
@@ -198,6 +205,7 @@ export async function run(argv) {
198
205
  edited = compiled.flow;
199
206
  editedSidecars = compiled.sidecars;
200
207
  drafts = compiled.drafts;
208
+ lowering = compiled.lowering.map(onNode);
201
209
  diagnostics = checkEdited(compiled.built, edited, flowCheckOpts(library, bindings));
202
210
  }
203
211
  else {
@@ -229,6 +237,9 @@ export async function run(argv) {
229
237
  // check()'s errors follow the same rule; its warnings are printed as compile prints them.
230
238
  const { kept, refused, unplaced } = classifyDrafts(original, edited, baseline, drafts);
231
239
  const checked = classifyDrafts(original, edited, baseline, diagnostics.filter((d) => d.level === 'error'));
240
+ // A node the SDK cannot lower follows the draft rule, held to the baseline:
241
+ // kept only when the original's own round trip refuses it the same way.
242
+ const lowered = classifyLowering(original, edited, baseline, lowering, baselineCompile?.lowering.map(onNode));
232
243
  for (const d of diagnostics)
233
244
  if (d.level === 'warning')
234
245
  console.error(` ⚠ [${d.code}] ${d.message}`);
@@ -237,6 +248,14 @@ export async function run(argv) {
237
248
  ? ` ⚠ [DRAFT_NODE_KEPT] "${d.nodeId}" is unfinished in the original (${d.code}); it is inside subflow "${d.subflow}", which the edit did not touch, so the original subflow is kept as is.`
238
249
  : ` ⚠ [DRAFT_NODE_KEPT] "${d.nodeId}" is unfinished in the original (${d.code}); it is untouched, so the original node is kept as is.`);
239
250
  }
251
+ for (const d of lowered.kept) {
252
+ const where = d.subflow !== undefined
253
+ ? `is inside subflow "${d.subflow}", which the edit did not touch, so the original subflow is kept as is`
254
+ : 'is untouched, so the original node is kept as is';
255
+ console.error(` ⚠ [LOWERING_NODE_KEPT] "${d.nodeId}" ${where}, but the SDK cannot compile it (${d.code}). `
256
+ + 'This is probably an SDK defect: the SDK cannot read what the designer wrote. Report it: https://github.com/UiPath/builder-sdk/issues');
257
+ console.error(` ${d.message}`);
258
+ }
240
259
  // A finding merge cannot place on a node — an unnamed End's `.return`, a
241
260
  // flow-level finding — is printed when the ORIGINAL's own round trip reports
242
261
  // it too (the designer's file had it; check has already said it), and
@@ -245,16 +264,18 @@ export async function run(argv) {
245
264
  // one is refused, as before.
246
265
  const baselineFindings = baselineCompile === undefined ? undefined : findingCounts([
247
266
  ...baselineCompile.drafts,
267
+ ...baselineCompile.lowering,
248
268
  ...checkEdited(baselineCompile.built, baselineCompile.flow, flowCheckOpts(library, bindings)).filter((d) => d.level === 'error'),
249
269
  ]);
250
270
  const unplacedDrafts = splitUnplaced(unplaced, baselineFindings);
251
271
  const unplacedChecks = splitUnplaced(checked.unplaced, baselineFindings);
252
- for (const d of [...unplacedDrafts.known, ...unplacedChecks.known]) {
272
+ const unplacedLowering = splitUnplaced(lowered.unplaced, baselineFindings);
273
+ for (const d of [...unplacedDrafts.known, ...unplacedChecks.known, ...unplacedLowering.known]) {
253
274
  console.error(` ⚠ [CHECK_FINDING_UNPLACED] ${d.code} names no node, and the original's own round trip reports it too, so it does not stop the write: ${d.message}`);
254
275
  }
255
276
  const errors = [
256
- ...refused, ...checked.refused,
257
- ...[...unplacedDrafts.introduced, ...unplacedChecks.introduced].map((d) => ({ ...d, reason: 'unplaced' })),
277
+ ...refused, ...checked.refused, ...lowered.refused,
278
+ ...[...unplacedDrafts.introduced, ...unplacedChecks.introduced, ...unplacedLowering.introduced].map((d) => ({ ...d, reason: 'unplaced' })),
258
279
  ];
259
280
  if (errors.length) {
260
281
  for (const d of errors) {
@@ -262,10 +283,17 @@ export async function run(argv) {
262
283
  ? 'the edited flow (this finding names no node, and the original\'s own round trip does not report it, so the edit introduced it)'
263
284
  : d.reason === 'subflow'
264
285
  ? `"${d.nodeId}" is inside subflow "${d.subflow}", which the edit changed, so merge takes that subflow whole; finish it`
265
- : `"${d.nodeId}" is changed or new in the edited flow`;
286
+ : d.reason === 'introduced'
287
+ ? `"${d.nodeId}" is untouched, but the original's own round trip does not refuse it, so an edit elsewhere broke it`
288
+ : d.reason === 'unrestorable'
289
+ ? unrestorable(d.nodeId, d.code)
290
+ : d.reason === 'uncompared'
291
+ ? `"${d.nodeId}" is the original's, but the SDK cannot compile it, and ${baseline ? 'a precompiled --baseline has no record of' : 'with no baseline merge cannot tell'} `
292
+ + 'whether the edit touched it; pass --baseline-from <original.flow> (instead of --baseline) so merge compiles the original itself and can compare the node'
293
+ : `"${d.nodeId}" is changed or new in the edited flow`;
266
294
  console.error(` ✗ [${d.code}] ${where}: ${d.message}`);
267
295
  }
268
- console.error(`flow-merge: ${errors.length} error(s) in nodes merge takes from the edited flow; finish them or revert the edit. Nothing written.`);
296
+ console.error(`flow-merge: ${errors.length} error(s) in nodes merge takes from the edited flow; each line says what to do (finish the node, revert the edit, or pass what merge needs). Nothing written.`);
269
297
  return 1;
270
298
  }
271
299
  // Each compile writes `<source>/` beside its own output, so a baseline in the
@@ -345,4 +373,23 @@ export async function run(argv) {
345
373
  });
346
374
  return 0;
347
375
  }
376
+ /**
377
+ * The error for a refusal on an untouched node that restoring it from the
378
+ * original would not undo, with the step to take: a crash is an SDK defect;
379
+ * a missing input to compile (the node library) is the caller's to pass; any
380
+ * other is about the edges merge takes from the edited flow.
381
+ */
382
+ function unrestorable(nodeId, code) {
383
+ const report = 'https://github.com/UiPath/builder-sdk/issues';
384
+ if (code === 'COMPILE_FAILED')
385
+ return `"${nodeId}" is untouched, but the SDK failed while compiling it; this is an SDK defect, report it: ${report}`;
386
+ if (code === 'LIBRARY_REQUIRED')
387
+ return `"${nodeId}" is untouched, but compiling it needs the node library; pass --library <dir> and merge again`;
388
+ return `"${nodeId}" is untouched, but restoring it from the original would not undo this: revert the edit's changes to the edges `
389
+ + `into or out of it; if there are none, this is an SDK defect, report it: ${report}`;
390
+ }
391
+ /** A collect-mode refusal as edit-scope.ts sorts it: on its node, or on none (`''`). */
392
+ function onNode(r) {
393
+ return { nodeId: r.nodeId ?? '', code: r.code, message: r.message };
394
+ }
348
395
  runWhenInvokedDirectly(import.meta.url, 'flow-merge', run);
@@ -1,36 +1,55 @@
1
- import { type DraftFinding, type SerializeOpts } from './serialize.js';
1
+ import { type CollectedRefusal, type DraftFinding, type SerializeOpts } from './serialize.js';
2
2
  import { type CheckOpts } from './check.js';
3
3
  import { type PlacedDiagnostic } from './edit-scope.js';
4
4
  import type { BuiltFlow } from './flow-sdk.js';
5
5
  /**
6
- * A compile's flow, the draft refusals it reported instead of throwing, and the
7
- * agent sidecars (`<source>/agent.json`, resource files) it produced, none written.
6
+ * A compile's flow, the draft refusals it reported instead of throwing, the
7
+ * lowering refusals collect mode reported on the steps it stood in for (or on
8
+ * no step), and the agent sidecars (`<source>/agent.json`, resource files) it
9
+ * produced, none written.
8
10
  */
9
11
  export interface DraftTolerantCompile {
10
12
  flow: any;
11
13
  drafts: DraftFinding[];
14
+ lowering: CollectedRefusal[];
12
15
  sidecars: Array<{
13
16
  path: string;
14
17
  json: unknown;
15
18
  }>;
16
19
  }
17
- /** Compile `built` with draft tolerance. Every other refusal still throws. */
18
- export declare function compileDraftTolerant(built: BuiltFlow, opts?: Omit<SerializeOpts, 'draft'>): DraftTolerantCompile;
20
+ /**
21
+ * Compile `built` with draft tolerance, in collect mode: a step whose
22
+ * emission is refused is reported in `lowering` and stood in for, so the
23
+ * rest of the flow is still compiled. A scope-level refusal (two steps with
24
+ * one name, a variable plan) leaves no flow to merge, so it is thrown, as an
25
+ * ordinary compile throws it.
26
+ */
27
+ export declare function compileDraftTolerant(built: BuiltFlow, opts?: Omit<SerializeOpts, 'draft' | 'collect'>): DraftTolerantCompile;
19
28
  /**
20
29
  * Load a `.flow.ts` source and compile it with draft tolerance.
21
30
  * The caller has already called `ensureTypeScriptRuntime()`.
22
31
  */
23
- export declare function compileSourceDraftTolerant(file: string, opts?: Omit<SerializeOpts, 'draft'>): Promise<DraftTolerantCompile & {
32
+ export declare function compileSourceDraftTolerant(file: string, opts?: Omit<SerializeOpts, 'draft' | 'collect'>): Promise<DraftTolerantCompile & {
24
33
  built: BuiltFlow;
25
34
  }>;
26
35
  /**
27
36
  * The baseline of `originalPath`: decompile it (reading agent sidecars beside
28
- * it, as `flow decompile` does), then compile the source with draft tolerance.
37
+ * it, as `flow decompile` does), then compile the source with draft tolerance,
38
+ * in collect mode ({@link compileDraftTolerant}).
29
39
  * The source goes to a temporary directory that is removed afterwards.
30
40
  */
31
- export declare function baselineFromOriginal(originalPath: string, opts?: Omit<SerializeOpts, 'draft'>, warn?: (message: string) => void): Promise<DraftTolerantCompile & {
41
+ export declare function baselineFromOriginal(originalPath: string, opts?: Omit<SerializeOpts, 'draft' | 'collect'>, warn?: (message: string) => void): Promise<DraftTolerantCompile & {
32
42
  built: BuiltFlow;
33
43
  }>;
44
+ /**
45
+ * Decompile the `.flow` at `originalPath` (reading agent sidecars beside it, as
46
+ * `flow decompile` does), load the source, and hand the built flow to `use`
47
+ * while the source is still on disk. `flow merge --baseline-from` compiles it
48
+ * ({@link baselineFromOriginal}); `flow check --baseline` checks it. The source
49
+ * goes to a temporary directory that is removed afterwards. The caller has
50
+ * already called `ensureTypeScriptRuntime()`.
51
+ */
52
+ export declare function withDecompiledOriginal<T>(originalPath: string, use: (built: BuiltFlow) => T | Promise<T>, warn?: (message: string) => void): Promise<T>;
34
53
  /**
35
54
  * Run `check()` on the edited source, as `compile` does, and place each
36
55
  * diagnostic on its node ({@link placeDiagnostics}). The `check()` call stays
@@ -1,11 +1,14 @@
1
1
  /**
2
- * merge-compile — the compiles `flow merge --baseline-from` runs itself (#1134).
2
+ * merge-compile — the compiles `flow merge --baseline-from` runs itself (#1134),
3
+ * and the decompile of an original `.flow` that it and `flow check --baseline` share.
3
4
  *
4
- * Both compiles tolerate draft nodes (`SerializeOpts.draft`). A draft on a node
5
- * merge keeps from the original is a warning; a draft or `check()` error on a
6
- * node merge takes from the edited flow is an error. Nothing draft-tolerant is
7
- * written to disk. Which nodes count as edited, and how each finding is sorted,
8
- * is edit-scope.ts's.
5
+ * Both compiles tolerate draft nodes (`SerializeOpts.draft`) and run in collect
6
+ * mode (`SerializeOpts.collect`), so a node that fails to lower is reported and
7
+ * stood in for instead of stopping the compile. A draft or lowering refusal on
8
+ * a node merge keeps from the original is a warning; one on a node merge takes
9
+ * from the edited flow, like a `check()` error there, is an error. Nothing
10
+ * these compiles produce is written to disk. Which nodes count as edited, and
11
+ * how each finding is sorted, is edit-scope.ts's.
9
12
  */
10
13
  import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
11
14
  import { tmpdir } from 'node:os';
@@ -13,15 +16,28 @@ import { dirname, join, resolve } from 'node:path';
13
16
  import { decompile } from './decompile.js';
14
17
  import { loadBuiltFlow } from './load.js';
15
18
  import { serialize } from './serialize.js';
19
+ import { CompileRefusal } from './core/compile-refusal.js';
16
20
  import { PACKAGE_NAME } from './package-name.js';
17
21
  import { check } from './check.js';
18
22
  import { placeDiagnostics } from './edit-scope.js';
19
- /** Compile `built` with draft tolerance. Every other refusal still throws. */
23
+ /**
24
+ * Compile `built` with draft tolerance, in collect mode: a step whose
25
+ * emission is refused is reported in `lowering` and stood in for, so the
26
+ * rest of the flow is still compiled. A scope-level refusal (two steps with
27
+ * one name, a variable plan) leaves no flow to merge, so it is thrown, as an
28
+ * ordinary compile throws it.
29
+ */
20
30
  export function compileDraftTolerant(built, opts = {}) {
21
31
  const drafts = [];
32
+ const lowering = [];
22
33
  const sidecars = [];
23
- const flow = serialize(built, { ...opts, draft: (f) => drafts.push(f), sidecars });
24
- return { flow, drafts, sidecars };
34
+ const flow = serialize(built, { ...opts, draft: (f) => drafts.push(f), collect: (r) => lowering.push(r), sidecars });
35
+ if (flow === undefined) {
36
+ // Collection stopped: the refusal that stopped it is the last one reported.
37
+ const stop = lowering[lowering.length - 1];
38
+ throw new CompileRefusal(stop.code, stop.message, stop.suggestion);
39
+ }
40
+ return { flow, drafts, lowering, sidecars };
25
41
  }
26
42
  /**
27
43
  * Load a `.flow.ts` source and compile it with draft tolerance.
@@ -33,10 +49,22 @@ export async function compileSourceDraftTolerant(file, opts = {}) {
33
49
  }
34
50
  /**
35
51
  * The baseline of `originalPath`: decompile it (reading agent sidecars beside
36
- * it, as `flow decompile` does), then compile the source with draft tolerance.
52
+ * it, as `flow decompile` does), then compile the source with draft tolerance,
53
+ * in collect mode ({@link compileDraftTolerant}).
37
54
  * The source goes to a temporary directory that is removed afterwards.
38
55
  */
39
56
  export async function baselineFromOriginal(originalPath, opts = {}, warn) {
57
+ return withDecompiledOriginal(originalPath, (built) => ({ ...compileDraftTolerant(built, opts), built }), warn);
58
+ }
59
+ /**
60
+ * Decompile the `.flow` at `originalPath` (reading agent sidecars beside it, as
61
+ * `flow decompile` does), load the source, and hand the built flow to `use`
62
+ * while the source is still on disk. `flow merge --baseline-from` compiles it
63
+ * ({@link baselineFromOriginal}); `flow check --baseline` checks it. The source
64
+ * goes to a temporary directory that is removed afterwards. The caller has
65
+ * already called `ensureTypeScriptRuntime()`.
66
+ */
67
+ export async function withDecompiledOriginal(originalPath, use, warn) {
40
68
  const original = JSON.parse(readFileSync(originalPath, 'utf8'));
41
69
  const source = decompile(original, {
42
70
  importSpecifier: PACKAGE_NAME,
@@ -60,7 +88,7 @@ export async function baselineFromOriginal(originalPath, opts = {}, warn) {
60
88
  writeFileSync(join(dir, 'package.json'), '{"type":"module"}\n');
61
89
  const file = join(dir, 'Baseline.flow.ts');
62
90
  writeFileSync(file, source);
63
- return await compileSourceDraftTolerant(file, opts);
91
+ return await use(await loadBuiltFlow(file));
64
92
  }
65
93
  finally {
66
94
  rmSync(dir, { recursive: true, force: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uipath/maestro-builder-sdk",
3
- "version": "6.61.1",
3
+ "version": "6.63.0",
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",
@@ -100,5 +100,5 @@
100
100
  "@types/node": "^22.7.0",
101
101
  "esbuild": "^0.28.1"
102
102
  },
103
- "gitref": "b4aa73a5f403c78c1b6bae2c136f6b281ba8fbac"
103
+ "gitref": "a4d62ecfe71de0865a81621edbf878ae84d0152b"
104
104
  }