@flow-as-code/core 0.1.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.
Files changed (164) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +30 -0
  3. package/SPEC.md +92 -0
  4. package/dist/actions.d.ts +87 -0
  5. package/dist/actions.d.ts.map +1 -0
  6. package/dist/actions.js +204 -0
  7. package/dist/actions.js.map +1 -0
  8. package/dist/aws.d.ts +27 -0
  9. package/dist/aws.d.ts.map +1 -0
  10. package/dist/aws.js +32 -0
  11. package/dist/aws.js.map +1 -0
  12. package/dist/blocks.d.ts +287 -0
  13. package/dist/blocks.d.ts.map +1 -0
  14. package/dist/blocks.js +352 -0
  15. package/dist/blocks.js.map +1 -0
  16. package/dist/codegen.d.ts +21 -0
  17. package/dist/codegen.d.ts.map +1 -0
  18. package/dist/codegen.js +1027 -0
  19. package/dist/codegen.js.map +1 -0
  20. package/dist/export.d.ts +256 -0
  21. package/dist/export.d.ts.map +1 -0
  22. package/dist/export.js +799 -0
  23. package/dist/export.js.map +1 -0
  24. package/dist/flow.d.ts +41 -0
  25. package/dist/flow.d.ts.map +1 -0
  26. package/dist/flow.js +85 -0
  27. package/dist/flow.js.map +1 -0
  28. package/dist/flowdoc.d.ts +111 -0
  29. package/dist/flowdoc.d.ts.map +1 -0
  30. package/dist/flowdoc.js +139 -0
  31. package/dist/flowdoc.js.map +1 -0
  32. package/dist/index.d.ts +16 -0
  33. package/dist/index.d.ts.map +1 -0
  34. package/dist/index.js +26 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/layout.d.ts +11 -0
  37. package/dist/layout.d.ts.map +1 -0
  38. package/dist/layout.js +49 -0
  39. package/dist/layout.js.map +1 -0
  40. package/dist/lint/engine.d.ts +16 -0
  41. package/dist/lint/engine.d.ts.map +1 -0
  42. package/dist/lint/engine.js +37 -0
  43. package/dist/lint/engine.js.map +1 -0
  44. package/dist/lint/graph.d.ts +34 -0
  45. package/dist/lint/graph.d.ts.map +1 -0
  46. package/dist/lint/graph.js +79 -0
  47. package/dist/lint/graph.js.map +1 -0
  48. package/dist/lint/index.d.ts +7 -0
  49. package/dist/lint/index.d.ts.map +1 -0
  50. package/dist/lint/index.js +5 -0
  51. package/dist/lint/index.js.map +1 -0
  52. package/dist/lint/reporters.d.ts +6 -0
  53. package/dist/lint/reporters.d.ts.map +1 -0
  54. package/dist/lint/reporters.js +30 -0
  55. package/dist/lint/reporters.js.map +1 -0
  56. package/dist/lint/rules/action-allowed-in-flow-type.d.ts +11 -0
  57. package/dist/lint/rules/action-allowed-in-flow-type.d.ts.map +1 -0
  58. package/dist/lint/rules/action-allowed-in-flow-type.js +32 -0
  59. package/dist/lint/rules/action-allowed-in-flow-type.js.map +1 -0
  60. package/dist/lint/rules/action-count.d.ts +16 -0
  61. package/dist/lint/rules/action-count.d.ts.map +1 -0
  62. package/dist/lint/rules/action-count.js +32 -0
  63. package/dist/lint/rules/action-count.js.map +1 -0
  64. package/dist/lint/rules/error-branches.d.ts +10 -0
  65. package/dist/lint/rules/error-branches.d.ts.map +1 -0
  66. package/dist/lint/rules/error-branches.js +39 -0
  67. package/dist/lint/rules/error-branches.js.map +1 -0
  68. package/dist/lint/rules/index.d.ts +17 -0
  69. package/dist/lint/rules/index.d.ts.map +1 -0
  70. package/dist/lint/rules/index.js +36 -0
  71. package/dist/lint/rules/index.js.map +1 -0
  72. package/dist/lint/rules/module-depth-5.d.ts +14 -0
  73. package/dist/lint/rules/module-depth-5.d.ts.map +1 -0
  74. package/dist/lint/rules/module-depth-5.js +67 -0
  75. package/dist/lint/rules/module-depth-5.js.map +1 -0
  76. package/dist/lint/rules/no-literal-arn.d.ts +21 -0
  77. package/dist/lint/rules/no-literal-arn.d.ts.map +1 -0
  78. package/dist/lint/rules/no-literal-arn.js +58 -0
  79. package/dist/lint/rules/no-literal-arn.js.map +1 -0
  80. package/dist/lint/rules/no-unresolved-token.d.ts +8 -0
  81. package/dist/lint/rules/no-unresolved-token.d.ts.map +1 -0
  82. package/dist/lint/rules/no-unresolved-token.js +71 -0
  83. package/dist/lint/rules/no-unresolved-token.js.map +1 -0
  84. package/dist/lint/rules/prompt-length-3000.d.ts +16 -0
  85. package/dist/lint/rules/prompt-length-3000.d.ts.map +1 -0
  86. package/dist/lint/rules/prompt-length-3000.js +61 -0
  87. package/dist/lint/rules/prompt-length-3000.js.map +1 -0
  88. package/dist/lint/rules/reachable-blocks.d.ts +7 -0
  89. package/dist/lint/rules/reachable-blocks.d.ts.map +1 -0
  90. package/dist/lint/rules/reachable-blocks.js +45 -0
  91. package/dist/lint/rules/reachable-blocks.js.map +1 -0
  92. package/dist/lint/rules/recording-consent-before-record.d.ts +3 -0
  93. package/dist/lint/rules/recording-consent-before-record.d.ts.map +1 -0
  94. package/dist/lint/rules/recording-consent-before-record.js +87 -0
  95. package/dist/lint/rules/recording-consent-before-record.js.map +1 -0
  96. package/dist/lint/rules/terminal-blocks.d.ts +8 -0
  97. package/dist/lint/rules/terminal-blocks.d.ts.map +1 -0
  98. package/dist/lint/rules/terminal-blocks.js +40 -0
  99. package/dist/lint/rules/terminal-blocks.js.map +1 -0
  100. package/dist/lint/rules/unique-names.d.ts +7 -0
  101. package/dist/lint/rules/unique-names.d.ts.map +1 -0
  102. package/dist/lint/rules/unique-names.js +40 -0
  103. package/dist/lint/rules/unique-names.js.map +1 -0
  104. package/dist/lint/types.d.ts +30 -0
  105. package/dist/lint/types.d.ts.map +1 -0
  106. package/dist/lint/types.js +11 -0
  107. package/dist/lint/types.js.map +1 -0
  108. package/dist/materialize.d.ts +31 -0
  109. package/dist/materialize.d.ts.map +1 -0
  110. package/dist/materialize.js +192 -0
  111. package/dist/materialize.js.map +1 -0
  112. package/dist/package-names.d.ts +11 -0
  113. package/dist/package-names.d.ts.map +1 -0
  114. package/dist/package-names.js +25 -0
  115. package/dist/package-names.js.map +1 -0
  116. package/dist/refs.d.ts +35 -0
  117. package/dist/refs.d.ts.map +1 -0
  118. package/dist/refs.js +69 -0
  119. package/dist/refs.js.map +1 -0
  120. package/dist/serialize.d.ts +13 -0
  121. package/dist/serialize.d.ts.map +1 -0
  122. package/dist/serialize.js +86 -0
  123. package/dist/serialize.js.map +1 -0
  124. package/dist/simulate.d.ts +446 -0
  125. package/dist/simulate.d.ts.map +1 -0
  126. package/dist/simulate.js +1115 -0
  127. package/dist/simulate.js.map +1 -0
  128. package/dist/synth.d.ts +25 -0
  129. package/dist/synth.d.ts.map +1 -0
  130. package/dist/synth.js +93 -0
  131. package/dist/synth.js.map +1 -0
  132. package/package.json +73 -0
  133. package/src/actions.ts +221 -0
  134. package/src/aws.ts +55 -0
  135. package/src/blocks.ts +619 -0
  136. package/src/codegen.ts +1144 -0
  137. package/src/export.ts +1190 -0
  138. package/src/flow.ts +108 -0
  139. package/src/flowdoc.ts +261 -0
  140. package/src/index.ts +31 -0
  141. package/src/layout.ts +54 -0
  142. package/src/lint/engine.ts +56 -0
  143. package/src/lint/graph.ts +86 -0
  144. package/src/lint/index.ts +10 -0
  145. package/src/lint/reporters.ts +44 -0
  146. package/src/lint/rules/action-allowed-in-flow-type.ts +32 -0
  147. package/src/lint/rules/action-count.ts +35 -0
  148. package/src/lint/rules/error-branches.ts +45 -0
  149. package/src/lint/rules/index.ts +53 -0
  150. package/src/lint/rules/module-depth-5.ts +70 -0
  151. package/src/lint/rules/no-literal-arn.ts +64 -0
  152. package/src/lint/rules/no-unresolved-token.ts +80 -0
  153. package/src/lint/rules/prompt-length-3000.ts +66 -0
  154. package/src/lint/rules/reachable-blocks.ts +50 -0
  155. package/src/lint/rules/recording-consent-before-record.ts +90 -0
  156. package/src/lint/rules/terminal-blocks.ts +45 -0
  157. package/src/lint/rules/unique-names.ts +44 -0
  158. package/src/lint/types.ts +43 -0
  159. package/src/materialize.ts +230 -0
  160. package/src/package-names.ts +26 -0
  161. package/src/refs.ts +93 -0
  162. package/src/serialize.ts +97 -0
  163. package/src/simulate.ts +1610 -0
  164. package/src/synth.ts +110 -0
package/src/codegen.ts ADDED
@@ -0,0 +1,1144 @@
1
+ /*
2
+ * Copyright 2026 The flow-as-code Authors
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ // codegen: FlowDoc in, idiomatic TypeScript out.
6
+ //
7
+ // The output is what a person would have written against the builder API: one
8
+ // exported zero-arg factory returning a Flow (or FlowModule), with one inline
9
+ // block construction per Action, in doc order. Synth preserves declaration
10
+ // order (docs/adr/0003-action-ordering.md), so emitting doc order makes
11
+ // synth(codegen(doc)) reproduce the doc with no reordering step.
12
+ //
13
+ // Invariants:
14
+ // - Pure and byte-stable: the same FlowDoc yields the identical string.
15
+ // - Prettier-clean as written: the printer mirrors the repo Prettier config
16
+ // (2-space indent, double quotes, trailing commas, printWidth 100), and a
17
+ // test formats the output to prove it is a fixed point.
18
+ // - Lossless: an Action of a modeled Type that the block class cannot
19
+ // reproduce exactly falls back to GenericBlock, verified by re-synthesizing
20
+ // every candidate block and comparing against the source Action.
21
+ // - Reference tokens in modeled reference-bearing fields become Refs.* calls;
22
+ // JSONPath strings become jsonPath(...); tokens inside GenericBlock
23
+ // parameters stay verbatim strings and are never rewritten.
24
+
25
+ import {
26
+ ActionType,
27
+ DTMF_DIGITS,
28
+ INPUT_TIME_LIMIT_EXCEEDED,
29
+ NO_MATCHING_CONDITION,
30
+ NO_MATCHING_ERROR,
31
+ REFERENCE_FIELDS,
32
+ } from "./actions.js";
33
+ import type { DtmfDigit } from "./actions.js";
34
+ import type { Block, DtmfBranch, GenericBlockConfig, MessageBody } from "./blocks.js";
35
+ import {
36
+ CheckHoursOfOperation,
37
+ Compare,
38
+ DisconnectParticipant,
39
+ EndFlowExecution,
40
+ EndFlowModuleExecution,
41
+ GenericBlock,
42
+ GetParticipantInput,
43
+ InvokeFlowModule,
44
+ InvokeLambdaFunction,
45
+ MessageParticipant,
46
+ TransferContactToQueue,
47
+ TransferToFlow,
48
+ UpdateContactAttributes,
49
+ UpdateContactRecordingBehavior,
50
+ UpdateContactTargetQueue,
51
+ } from "./blocks.js";
52
+ import type {
53
+ ConditionOperator,
54
+ ErrorTransition,
55
+ FlowAction,
56
+ FlowDoc,
57
+ Point,
58
+ RefType,
59
+ Transitions,
60
+ } from "./flowdoc.js";
61
+ import { assertFlowDoc } from "./flowdoc.js";
62
+ import { autoLayout } from "./layout.js";
63
+ import { PACKAGE_NAMES } from "./package-names.js";
64
+ import { parseToken } from "./refs.js";
65
+
66
+ export interface CodegenOptions {
67
+ /** Import path for the builder API. Defaults to the published package. */
68
+ moduleSpecifier?: string;
69
+ /**
70
+ * A previously generated source for the same flow. Comment lines containing
71
+ * `@keep` that immediately precede a block construction re-attach to the
72
+ * block with the same id; ones immediately preceding the export function
73
+ * re-attach there. Everything else regenerates.
74
+ */
75
+ previous?: string;
76
+ }
77
+
78
+ const DEFAULT_MODULE_SPECIFIER = PACKAGE_NAMES.core;
79
+ const PRINT_WIDTH = 100;
80
+
81
+ // The banner on generated files. No licence header: the output lands in a
82
+ // user's project and is their file, not ours, so stamping this repo's
83
+ // copyright on it would be wrong. The Terraform emitter's banner works the
84
+ // same way (packages/tf/src/emit.ts).
85
+ //
86
+ // Naming the document it came from makes a file found on its own explain
87
+ // itself, and `.flowdoc.json` is the name `flow-cli codegen` reads.
88
+ function generatedMarker(name: string): string[] {
89
+ return [
90
+ `// Generated by ${PACKAGE_NAMES.core} codegen from ${name}.flowdoc.json.`,
91
+ "// Edits to structure regenerate; comments marked @keep survive.",
92
+ ];
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Value model for the printer
97
+ // ---------------------------------------------------------------------------
98
+
99
+ /** A code fragment emitted verbatim, e.g. `Refs.queue("front-desk")`. */
100
+ class Raw {
101
+ constructor(readonly code: string) {}
102
+ }
103
+
104
+ /** An object literal with a fixed entry order. */
105
+ class ObjV {
106
+ constructor(readonly entries: [string, V][]) {}
107
+ }
108
+
109
+ class ArrV {
110
+ constructor(readonly items: V[]) {}
111
+ }
112
+
113
+ type V = string | number | boolean | null | Raw | ObjV | ArrV;
114
+
115
+ /** Arbitrary JSON (GenericBlock parameters) to the value model, keys sorted. */
116
+ function toV(value: unknown): V {
117
+ if (value === null || typeof value === "string" || typeof value === "number") return value;
118
+ if (typeof value === "boolean") return value;
119
+ if (Array.isArray(value)) return new ArrV(value.map(toV));
120
+ if (typeof value === "object") {
121
+ const entries = Object.entries(value as Record<string, unknown>)
122
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
123
+ .map(([k, v]) => [k, toV(v)] as [string, V]);
124
+ return new ObjV(entries);
125
+ }
126
+ throw new Error(`Cannot emit a ${typeof value} value.`);
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Printer. Mirrors the repo Prettier config so the output is a Prettier fixed
131
+ // point: double quotes unless the string holds more double than single quotes,
132
+ // keys quoted only when not valid identifiers, objects kept expanded once
133
+ // broken (Prettier objectWrap: preserve), arrays force-broken when every
134
+ // element is an object with two or more properties.
135
+ // ---------------------------------------------------------------------------
136
+
137
+ const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
138
+
139
+ function quoteString(s: string): string {
140
+ const doubles = (s.match(/"/g) ?? []).length;
141
+ const singles = (s.match(/'/g) ?? []).length;
142
+ const q = doubles > singles ? "'" : '"';
143
+ let out = q;
144
+ for (const ch of s) {
145
+ if (ch === "\\") out += "\\\\";
146
+ else if (ch === q) out += `\\${q}`;
147
+ else if (ch === "\n") out += "\\n";
148
+ else if (ch === "\r") out += "\\r";
149
+ else if (ch === "\t") out += "\\t";
150
+ else if (
151
+ ch < " " ||
152
+ ch === "\u2028" ||
153
+ ch === "\u2029" ||
154
+ (ch.codePointAt(0)! >= 0xd800 && ch.codePointAt(0)! <= 0xdfff)
155
+ ) {
156
+ // Control characters, JS line separators, and lone surrogates must be
157
+ // escaped: an unpaired surrogate written to a utf8 file becomes U+FFFD,
158
+ // which would break the file-based round trip.
159
+ out += `\\u${ch.codePointAt(0)!.toString(16).padStart(4, "0")}`;
160
+ } else out += ch;
161
+ }
162
+ return out + q;
163
+ }
164
+
165
+ function keySource(key: string): string {
166
+ return IDENTIFIER.test(key) ? key : quoteString(key);
167
+ }
168
+
169
+ /** Prettier breaks such arrays regardless of width. */
170
+ function arrayForcesBreak(a: ArrV): boolean {
171
+ return a.items.length > 1 && a.items.every((i) => i instanceof ObjV && i.entries.length >= 2);
172
+ }
173
+
174
+ /** Single-line rendering, or undefined when the value must break. */
175
+ function inlineV(v: V): string | undefined {
176
+ if (v instanceof Raw) return v.code;
177
+ if (typeof v === "string") return quoteString(v);
178
+ if (typeof v === "number" || typeof v === "boolean" || v === null) return String(v);
179
+ if (v instanceof ArrV) {
180
+ if (arrayForcesBreak(v)) return undefined;
181
+ const items = v.items.map(inlineV);
182
+ if (items.some((i) => i === undefined)) return undefined;
183
+ return `[${items.join(", ")}]`;
184
+ }
185
+ if (v.entries.length === 0) return "{}";
186
+ const parts = v.entries.map(([k, val]) => {
187
+ const inner = inlineV(val);
188
+ return inner === undefined ? undefined : `${keySource(k)}: ${inner}`;
189
+ });
190
+ if (parts.some((p) => p === undefined)) return undefined;
191
+ return `{ ${parts.join(", ")} }`;
192
+ }
193
+
194
+ /**
195
+ * Renders a value starting at column `col` on a line indented by `indent`.
196
+ * Continuation lines carry their own indentation; the first line does not.
197
+ * One column is reserved for the trailing comma the caller appends.
198
+ */
199
+ function printV(v: V, col: number, indent: string): string {
200
+ const flat = inlineV(v);
201
+ if (flat !== undefined && col + flat.length + 1 <= PRINT_WIDTH) return flat;
202
+ const inner = `${indent} `;
203
+ if (v instanceof ObjV && v.entries.length > 0) return printBrokenObject(v.entries, indent);
204
+ if (v instanceof ArrV && v.items.length > 0) {
205
+ const lines = v.items.map((i) => `${inner}${printV(i, inner.length, inner)},`);
206
+ return `[\n${lines.join("\n")}\n${indent}]`;
207
+ }
208
+ // Primitives and Raw fragments cannot break; emit even when over width.
209
+ return flat ?? "";
210
+ }
211
+
212
+ function printBrokenObject(entries: [string, V][], indent: string): string {
213
+ const inner = `${indent} `;
214
+ const lines = entries.map(([k, val]) => {
215
+ const prefix = `${inner}${keySource(k)}: `;
216
+ return `${prefix}${printV(val, prefix.length, inner)},`;
217
+ });
218
+ return `{\n${lines.join("\n")}\n${indent}}`;
219
+ }
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // Inversion: FlowAction back to a block construction.
223
+ //
224
+ // Every candidate is verified by constructing the real block and comparing its
225
+ // toAction() output against the source Action; on any mismatch the Action is
226
+ // emitted as a GenericBlock instead, so the round-trip stays lossless.
227
+ // ---------------------------------------------------------------------------
228
+
229
+ interface Inversion {
230
+ /** Block class name, also the import to emit. */
231
+ cls: string;
232
+ /** Config object entries in emission order. */
233
+ entries: [string, V][];
234
+ /** The constructed block, used to verify the inversion is exact. */
235
+ block: Block;
236
+ }
237
+
238
+ interface Ctx {
239
+ refs: boolean;
240
+ jsonPath: boolean;
241
+ }
242
+
243
+ function stableJson(value: unknown): string {
244
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
245
+ if (value !== null && typeof value === "object") {
246
+ const keys = Object.keys(value as Record<string, unknown>).sort();
247
+ const body = keys
248
+ .map((k) => `${JSON.stringify(k)}:${stableJson((value as Record<string, unknown>)[k])}`)
249
+ .join(",");
250
+ return `{${body}}`;
251
+ }
252
+ return JSON.stringify(value);
253
+ }
254
+
255
+ const cast = <T>(value: unknown): T => value as T;
256
+
257
+ /**
258
+ * A reference-bearing field value as builder source. Well-formed tokens of the
259
+ * expected type become Refs.* calls, JSONPath strings become jsonPath(...),
260
+ * anything else is inexpressible and forces the GenericBlock fallback.
261
+ * https://docs.aws.amazon.com/connect/latest/devguide/flow-language-actions.html
262
+ * documents these fields as fully static or a single JSONPath identifier.
263
+ */
264
+ function refSource(value: unknown, type: RefType, ctx: Ctx): Raw | undefined {
265
+ if (typeof value !== "string") return undefined;
266
+ const entry = parseToken(value);
267
+ if (entry !== undefined) {
268
+ if (entry.type !== type) return undefined;
269
+ if (type === "module") {
270
+ if (entry.alias === undefined) return undefined; // Refs.module requires an alias
271
+ ctx.refs = true;
272
+ return new Raw(`Refs.module(${quoteString(entry.name)}, ${quoteString(entry.alias)})`);
273
+ }
274
+ if (entry.alias !== undefined) return undefined; // only module refs carry aliases
275
+ ctx.refs = true;
276
+ return new Raw(`Refs.${type}(${quoteString(entry.name)})`);
277
+ }
278
+ if (/^\$\.[A-Za-z0-9_$.[\]'-]+$/.test(value)) {
279
+ ctx.jsonPath = true;
280
+ return new Raw(`jsonPath(${quoteString(value)})`);
281
+ }
282
+ return undefined;
283
+ }
284
+
285
+ function paramKeysAre(
286
+ params: Record<string, unknown>,
287
+ required: string[],
288
+ optional: string[] = [],
289
+ ): boolean {
290
+ const keys = Object.keys(params);
291
+ return (
292
+ required.every((k) => keys.includes(k)) &&
293
+ keys.every((k) => required.includes(k) || optional.includes(k))
294
+ );
295
+ }
296
+
297
+ /** NextAction plus exactly one catch-all error and no conditions. */
298
+ function wiredTransitions(
299
+ t: Transitions,
300
+ errorType: string,
301
+ ): { next: string; onError: string } | undefined {
302
+ if (t.NextAction === undefined) return undefined;
303
+ const errors = t.Errors ?? [];
304
+ if (errors.length !== 1 || errors[0]!.ErrorType !== errorType) return undefined;
305
+ if ((t.Conditions ?? []).length !== 0) return undefined;
306
+ return { next: t.NextAction, onError: errors[0]!.NextAction };
307
+ }
308
+
309
+ function isStringMap(value: unknown): value is Record<string, string> {
310
+ return (
311
+ value !== null &&
312
+ typeof value === "object" &&
313
+ !Array.isArray(value) &&
314
+ Object.values(value as Record<string, unknown>).every((v) => typeof v === "string")
315
+ );
316
+ }
317
+
318
+ const OPERATORS: readonly string[] = [
319
+ "Equals",
320
+ "TextStartsWith",
321
+ "TextEndsWith",
322
+ "TextContains",
323
+ "NumberGreaterThan",
324
+ "NumberGreaterOrEqualTo",
325
+ "NumberLessThan",
326
+ "NumberLessOrEqualTo",
327
+ ];
328
+
329
+ function isCondition(
330
+ value: unknown,
331
+ ): value is { NextAction: string; Condition: { Operator: ConditionOperator; Operands: string[] } } {
332
+ const c = value as {
333
+ NextAction?: unknown;
334
+ Condition?: { Operator?: unknown; Operands?: unknown };
335
+ };
336
+ return (
337
+ typeof c.NextAction === "string" &&
338
+ typeof c.Condition === "object" &&
339
+ c.Condition !== null &&
340
+ typeof c.Condition.Operator === "string" &&
341
+ OPERATORS.includes(c.Condition.Operator) &&
342
+ Array.isArray(c.Condition.Operands) &&
343
+ c.Condition.Operands.every((o) => typeof o === "string")
344
+ );
345
+ }
346
+
347
+ function terminal(a: FlowAction, make: () => Block): Inversion | undefined {
348
+ if (Object.keys(a.Parameters).length !== 0) return undefined;
349
+ if (Object.keys(a.Transitions).length !== 0) return undefined;
350
+ return { cls: a.Type, entries: [["id", a.Identifier]], block: make() };
351
+ }
352
+
353
+ /**
354
+ * One inverter per modeled action type; undefined means "not expressible".
355
+ *
356
+ * The correctness gate is not in here. invertAction constructs the candidate
357
+ * block, compares its synthesized Action with the source byte for byte, and
358
+ * falls back to GenericBlock on any difference or constructor throw. The early
359
+ * returns below are readable statements of each class's shape and a cheap way
360
+ * out before a constructor runs; most of them are redundant with that
361
+ * comparison, so neutering one does not change the output. The exception is
362
+ * the refSource check: the block would still verify with an unresolvable
363
+ * PromptId, but the emitted entries would carry `prompt: undefined`, so that
364
+ * return is load-bearing and is the one a test pins. The typeof checks on
365
+ * string parameters exist for the type checker; the comparison catches the
366
+ * JSON number spelling either way.
367
+ */
368
+ const INVERTERS: Record<string, (a: FlowAction, ctx: Ctx) => Inversion | undefined> = {
369
+ [ActionType.MessageParticipant]: (a, ctx) => {
370
+ const w = wiredTransitions(a.Transitions, NO_MATCHING_ERROR);
371
+ if (w === undefined) return undefined;
372
+ const p = a.Parameters;
373
+ const keys = Object.keys(p);
374
+ if (keys.length !== 1) return undefined;
375
+ const entries: [string, V][] = [["id", a.Identifier]];
376
+ if (keys[0] === "Text" && typeof p.Text === "string") entries.push(["text", p.Text]);
377
+ else if (keys[0] === "SSML" && typeof p.SSML === "string") entries.push(["ssml", p.SSML]);
378
+ else if (keys[0] === "PromptId") {
379
+ const ref = refSource(
380
+ p.PromptId,
381
+ REFERENCE_FIELDS[ActionType.MessageParticipant]!.PromptId!,
382
+ ctx,
383
+ );
384
+ if (ref === undefined) return undefined;
385
+ entries.push(["prompt", ref]);
386
+ } else return undefined;
387
+ entries.push(["next", w.next], ["onError", w.onError]);
388
+ const body =
389
+ keys[0] === "Text"
390
+ ? { text: cast<string>(p.Text) }
391
+ : keys[0] === "SSML"
392
+ ? { ssml: cast<string>(p.SSML) }
393
+ : { prompt: cast<never>(p.PromptId) };
394
+ return {
395
+ cls: "MessageParticipant",
396
+ entries,
397
+ block: new MessageParticipant({
398
+ id: a.Identifier,
399
+ ...body,
400
+ next: w.next,
401
+ onError: w.onError,
402
+ }),
403
+ };
404
+ },
405
+
406
+ [ActionType.GetParticipantInput]: (a, ctx) => {
407
+ const t = a.Transitions;
408
+ const errors = t.Errors ?? [];
409
+ // The class emits the menu form's three errors in this order.
410
+ const expected = [INPUT_TIME_LIMIT_EXCEEDED, NO_MATCHING_CONDITION, NO_MATCHING_ERROR];
411
+ if (errors.length !== expected.length) return undefined;
412
+ if (errors.some((e, i) => e.ErrorType !== expected[i])) return undefined;
413
+ const [onTimeout, onNoMatch, onError] = errors.map((e) => e.NextAction) as [
414
+ string,
415
+ string,
416
+ string,
417
+ ];
418
+ // The class mirrors NextAction onto the no-match path.
419
+ if (t.NextAction !== onNoMatch) return undefined;
420
+ const conditions = t.Conditions ?? [];
421
+ if (!conditions.every(isCondition)) return undefined;
422
+ const branches: DtmfBranch[] = [];
423
+ for (const c of conditions) {
424
+ if (c.Condition.Operator !== "Equals" || c.Condition.Operands.length !== 1) return undefined;
425
+ const digit = c.Condition.Operands[0]!;
426
+ if (!(DTMF_DIGITS as readonly string[]).includes(digit)) return undefined;
427
+ branches.push({ digit: digit as DtmfDigit, target: c.NextAction });
428
+ }
429
+ const p = a.Parameters;
430
+ if (!paramKeysAre(p, ["InputTimeLimitSeconds", "StoreInput"], ["Text", "SSML", "PromptId"])) {
431
+ return undefined;
432
+ }
433
+ // Only the menu form is modeled; stored input has no conditions and needs
434
+ // InputValidation, which the class does not emit.
435
+ if (p.StoreInput !== "False") return undefined;
436
+ // The class writes the timeout as a plain decimal string, so only that
437
+ // spelling inverts: "5", never 5, "05", or "5.0". The constructor holds
438
+ // the value to its documented range.
439
+ if (typeof p.InputTimeLimitSeconds !== "string") return undefined;
440
+ if (!/^[1-9][0-9]*$/.test(p.InputTimeLimitSeconds)) return undefined;
441
+ const timeoutSeconds = Number(p.InputTimeLimitSeconds);
442
+ const bodyKeys = ["Text", "SSML", "PromptId"].filter((k) => k in p);
443
+ if (bodyKeys.length > 1) return undefined;
444
+ const entries: [string, V][] = [["id", a.Identifier]];
445
+ let body: MessageBody | { text?: never; ssml?: never; prompt?: never } = {};
446
+ if (bodyKeys[0] === "Text" && typeof p.Text === "string") {
447
+ entries.push(["text", p.Text]);
448
+ body = { text: p.Text };
449
+ } else if (bodyKeys[0] === "SSML" && typeof p.SSML === "string") {
450
+ entries.push(["ssml", p.SSML]);
451
+ body = { ssml: p.SSML };
452
+ } else if (bodyKeys[0] === "PromptId") {
453
+ const ref = refSource(
454
+ p.PromptId,
455
+ REFERENCE_FIELDS[ActionType.GetParticipantInput]!.PromptId!,
456
+ ctx,
457
+ );
458
+ if (ref === undefined) return undefined;
459
+ entries.push(["prompt", ref]);
460
+ body = { prompt: cast<never>(p.PromptId) };
461
+ } else if (bodyKeys.length === 1) return undefined;
462
+ entries.push(
463
+ ["timeoutSeconds", timeoutSeconds],
464
+ [
465
+ "branches",
466
+ new ArrV(
467
+ branches.map(
468
+ (b) =>
469
+ new ObjV([
470
+ ["digit", b.digit],
471
+ ["target", b.target as string],
472
+ ]),
473
+ ),
474
+ ),
475
+ ],
476
+ ["onTimeout", onTimeout],
477
+ ["onNoMatch", onNoMatch],
478
+ ["onError", onError],
479
+ );
480
+ return {
481
+ cls: "GetParticipantInput",
482
+ entries,
483
+ block: new GetParticipantInput({
484
+ id: a.Identifier,
485
+ ...body,
486
+ timeoutSeconds,
487
+ branches,
488
+ onTimeout,
489
+ onNoMatch,
490
+ onError,
491
+ }),
492
+ };
493
+ },
494
+
495
+ [ActionType.DisconnectParticipant]: (a) =>
496
+ terminal(a, () => new DisconnectParticipant({ id: a.Identifier })),
497
+ [ActionType.EndFlowExecution]: (a) =>
498
+ terminal(a, () => new EndFlowExecution({ id: a.Identifier })),
499
+ [ActionType.EndFlowModuleExecution]: (a) =>
500
+ terminal(a, () => new EndFlowModuleExecution({ id: a.Identifier })),
501
+
502
+ [ActionType.CheckHoursOfOperation]: (a, ctx) => {
503
+ const t = a.Transitions;
504
+ const errors = t.Errors ?? [];
505
+ const conditions = t.Conditions ?? [];
506
+ if (errors.length !== 1 || errors[0]!.ErrorType !== NO_MATCHING_ERROR) return undefined;
507
+ if (conditions.length !== 2) return undefined;
508
+ const [inHours, outOfHours] = conditions;
509
+ if (stableJson(inHours!.Condition) !== stableJson({ Operator: "Equals", Operands: ["True"] })) {
510
+ return undefined;
511
+ }
512
+ if (
513
+ stableJson(outOfHours!.Condition) !== stableJson({ Operator: "Equals", Operands: ["False"] })
514
+ ) {
515
+ return undefined;
516
+ }
517
+ // The class mirrors NextAction onto the out-of-hours path.
518
+ if (t.NextAction !== outOfHours!.NextAction) return undefined;
519
+ if (!paramKeysAre(a.Parameters, [], ["HoursOfOperationId"])) return undefined;
520
+ const entries: [string, V][] = [["id", a.Identifier]];
521
+ let hours: string | undefined;
522
+ if ("HoursOfOperationId" in a.Parameters) {
523
+ const ref = refSource(a.Parameters.HoursOfOperationId, "hours", ctx);
524
+ if (ref === undefined) return undefined;
525
+ entries.push(["hours", ref]);
526
+ hours = cast<string>(a.Parameters.HoursOfOperationId);
527
+ }
528
+ entries.push(
529
+ ["onInHours", inHours!.NextAction],
530
+ ["onOutOfHours", outOfHours!.NextAction],
531
+ ["onError", errors[0]!.NextAction],
532
+ );
533
+ return {
534
+ cls: "CheckHoursOfOperation",
535
+ entries,
536
+ block: new CheckHoursOfOperation({
537
+ id: a.Identifier,
538
+ ...(hours === undefined ? {} : { hours: cast<never>(hours) }),
539
+ onInHours: inHours!.NextAction,
540
+ onOutOfHours: outOfHours!.NextAction,
541
+ onError: errors[0]!.NextAction,
542
+ }),
543
+ };
544
+ },
545
+
546
+ [ActionType.Compare]: (a, ctx) => {
547
+ const t = a.Transitions;
548
+ if (t.NextAction !== undefined) return undefined;
549
+ const errors = t.Errors ?? [];
550
+ if (errors.length !== 1 || errors[0]!.ErrorType !== NO_MATCHING_CONDITION) return undefined;
551
+ const conditions = t.Conditions ?? [];
552
+ if (conditions.length === 0 || !conditions.every(isCondition)) return undefined;
553
+ if (!paramKeysAre(a.Parameters, ["ComparisonValue"])) return undefined;
554
+ const value = a.Parameters.ComparisonValue;
555
+ if (typeof value !== "string" || !/^\$\.[A-Za-z0-9_$.[\]'-]+$/.test(value)) return undefined;
556
+ // The emitted config calls jsonPath(); record it so the import is present
557
+ // even when no other action pulls it in through refSource.
558
+ ctx.jsonPath = true;
559
+ const branches = conditions.map((c) => ({
560
+ operator: c.Condition.Operator,
561
+ operands: c.Condition.Operands,
562
+ target: c.NextAction,
563
+ }));
564
+ const entries: [string, V][] = [
565
+ ["id", a.Identifier],
566
+ ["value", new Raw(`jsonPath(${quoteString(value)})`)],
567
+ [
568
+ "branches",
569
+ new ArrV(
570
+ branches.map(
571
+ (b) =>
572
+ new ObjV([
573
+ ["operator", b.operator],
574
+ ["operands", new ArrV([...b.operands])],
575
+ ["target", b.target],
576
+ ]),
577
+ ),
578
+ ),
579
+ ],
580
+ ["onNoMatch", errors[0]!.NextAction],
581
+ ];
582
+ return {
583
+ cls: "Compare",
584
+ entries,
585
+ block: new Compare({
586
+ id: a.Identifier,
587
+ value: cast<never>(value),
588
+ branches,
589
+ onNoMatch: errors[0]!.NextAction,
590
+ }),
591
+ };
592
+ },
593
+
594
+ [ActionType.TransferToFlow]: (a, ctx) => {
595
+ const w = wiredTransitions(a.Transitions, NO_MATCHING_ERROR);
596
+ if (w === undefined || !paramKeysAre(a.Parameters, ["ContactFlowId"])) return undefined;
597
+ const ref = refSource(a.Parameters.ContactFlowId, "flow", ctx);
598
+ if (ref === undefined) return undefined;
599
+ return {
600
+ cls: "TransferToFlow",
601
+ entries: [
602
+ ["id", a.Identifier],
603
+ ["flow", ref],
604
+ ["next", w.next],
605
+ ["onError", w.onError],
606
+ ],
607
+ block: new TransferToFlow({
608
+ id: a.Identifier,
609
+ flow: cast<never>(a.Parameters.ContactFlowId),
610
+ next: w.next,
611
+ onError: w.onError,
612
+ }),
613
+ };
614
+ },
615
+
616
+ [ActionType.UpdateContactTargetQueue]: (a, ctx) => {
617
+ const w = wiredTransitions(a.Transitions, NO_MATCHING_ERROR);
618
+ if (w === undefined) return undefined;
619
+ const keys = Object.keys(a.Parameters);
620
+ if (keys.length !== 1 || (keys[0] !== "QueueId" && keys[0] !== "AgentId")) return undefined;
621
+ const field = keys[0];
622
+ const ref = refSource(a.Parameters[field], "queue", ctx);
623
+ if (ref === undefined) return undefined;
624
+ const prop = field === "QueueId" ? "queue" : "agent";
625
+ return {
626
+ cls: "UpdateContactTargetQueue",
627
+ entries: [
628
+ ["id", a.Identifier],
629
+ [prop, ref],
630
+ ["next", w.next],
631
+ ["onError", w.onError],
632
+ ],
633
+ block: new UpdateContactTargetQueue(
634
+ cast<never>({
635
+ id: a.Identifier,
636
+ [prop]: a.Parameters[field],
637
+ next: w.next,
638
+ onError: w.onError,
639
+ }),
640
+ ),
641
+ };
642
+ },
643
+
644
+ [ActionType.TransferContactToQueue]: (a) => {
645
+ const t = a.Transitions;
646
+ if (Object.keys(a.Parameters).length !== 0) return undefined;
647
+ if (t.NextAction === undefined || (t.Conditions ?? []).length !== 0) return undefined;
648
+ const errors = t.Errors ?? [];
649
+ if (
650
+ errors.length !== 2 ||
651
+ errors[0]!.ErrorType !== "QueueAtCapacity" ||
652
+ errors[1]!.ErrorType !== NO_MATCHING_ERROR
653
+ ) {
654
+ return undefined;
655
+ }
656
+ return {
657
+ cls: "TransferContactToQueue",
658
+ entries: [
659
+ ["id", a.Identifier],
660
+ ["next", t.NextAction],
661
+ ["onQueueAtCapacity", errors[0]!.NextAction],
662
+ ["onError", errors[1]!.NextAction],
663
+ ],
664
+ block: new TransferContactToQueue({
665
+ id: a.Identifier,
666
+ next: t.NextAction,
667
+ onQueueAtCapacity: errors[0]!.NextAction,
668
+ onError: errors[1]!.NextAction,
669
+ }),
670
+ };
671
+ },
672
+
673
+ [ActionType.UpdateContactAttributes]: (a) => {
674
+ const w = wiredTransitions(a.Transitions, NO_MATCHING_ERROR);
675
+ if (w === undefined) return undefined;
676
+ if (!paramKeysAre(a.Parameters, ["Attributes", "TargetContact"])) return undefined;
677
+ const attributes = a.Parameters.Attributes;
678
+ const target = a.Parameters.TargetContact;
679
+ if (!isStringMap(attributes)) return undefined;
680
+ if (target !== "Current" && target !== "Related") return undefined;
681
+ const entries: [string, V][] = [
682
+ ["id", a.Identifier],
683
+ ["attributes", toV(attributes)],
684
+ ];
685
+ if (target === "Related") entries.push(["targetContact", "Related"]);
686
+ entries.push(["next", w.next], ["onError", w.onError]);
687
+ return {
688
+ cls: "UpdateContactAttributes",
689
+ entries,
690
+ block: new UpdateContactAttributes({
691
+ id: a.Identifier,
692
+ attributes,
693
+ ...(target === "Related" ? { targetContact: target } : {}),
694
+ next: w.next,
695
+ onError: w.onError,
696
+ }),
697
+ };
698
+ },
699
+
700
+ [ActionType.UpdateContactRecordingBehavior]: (a) => {
701
+ const w = wiredTransitions(a.Transitions, NO_MATCHING_ERROR);
702
+ if (w === undefined || !paramKeysAre(a.Parameters, ["RecordingBehavior"])) return undefined;
703
+ const rb = a.Parameters.RecordingBehavior as Record<string, unknown> | null;
704
+ if (rb === null || typeof rb !== "object" || Array.isArray(rb)) return undefined;
705
+ if (
706
+ !paramKeysAre(
707
+ rb,
708
+ ["RecordedParticipants"],
709
+ ["ScreenRecordedParticipants", "IVRRecordingBehavior"],
710
+ )
711
+ ) {
712
+ return undefined;
713
+ }
714
+ const recorded = rb.RecordedParticipants;
715
+ if (!Array.isArray(recorded) || !recorded.every((p) => p === "Agent" || p === "Customer")) {
716
+ return undefined;
717
+ }
718
+ const entries: [string, V][] = [
719
+ ["id", a.Identifier],
720
+ ["recordedParticipants", new ArrV([...recorded])],
721
+ ];
722
+ const screen = rb.ScreenRecordedParticipants;
723
+ if (screen !== undefined) {
724
+ if (!Array.isArray(screen) || !screen.every((p) => p === "Agent")) return undefined;
725
+ entries.push(["screenRecordedParticipants", new ArrV([...screen])]);
726
+ }
727
+ const ivr = rb.IVRRecordingBehavior;
728
+ if (ivr !== undefined) {
729
+ if (ivr !== "Enabled" && ivr !== "Disabled") return undefined;
730
+ entries.push(["ivrRecordingBehavior", ivr]);
731
+ }
732
+ entries.push(["next", w.next], ["onError", w.onError]);
733
+ return {
734
+ cls: "UpdateContactRecordingBehavior",
735
+ entries,
736
+ block: new UpdateContactRecordingBehavior({
737
+ id: a.Identifier,
738
+ recordedParticipants: cast<never>(recorded),
739
+ ...(screen === undefined ? {} : { screenRecordedParticipants: cast<never>(screen) }),
740
+ ...(ivr === undefined ? {} : { ivrRecordingBehavior: ivr }),
741
+ next: w.next,
742
+ onError: w.onError,
743
+ }),
744
+ };
745
+ },
746
+
747
+ [ActionType.InvokeFlowModule]: (a, ctx) => {
748
+ const w = wiredTransitions(a.Transitions, NO_MATCHING_ERROR);
749
+ if (w === undefined || !paramKeysAre(a.Parameters, ["FlowModuleId"])) return undefined;
750
+ const ref = refSource(a.Parameters.FlowModuleId, "module", ctx);
751
+ if (ref === undefined) return undefined;
752
+ return {
753
+ cls: "InvokeFlowModule",
754
+ entries: [
755
+ ["id", a.Identifier],
756
+ ["module", ref],
757
+ ["next", w.next],
758
+ ["onError", w.onError],
759
+ ],
760
+ block: new InvokeFlowModule({
761
+ id: a.Identifier,
762
+ module: cast<never>(a.Parameters.FlowModuleId),
763
+ next: w.next,
764
+ onError: w.onError,
765
+ }),
766
+ };
767
+ },
768
+
769
+ [ActionType.InvokeLambdaFunction]: (a, ctx) => {
770
+ const w = wiredTransitions(a.Transitions, NO_MATCHING_ERROR);
771
+ if (w === undefined) return undefined;
772
+ const p = a.Parameters;
773
+ if (
774
+ !paramKeysAre(
775
+ p,
776
+ ["LambdaFunctionARN", "InvocationTimeLimitSeconds", "InvocationType"],
777
+ ["LambdaInvocationAttributes", "ResponseValidation"],
778
+ )
779
+ ) {
780
+ return undefined;
781
+ }
782
+ const ref = refSource(p.LambdaFunctionARN, "lambda", ctx);
783
+ if (ref === undefined) return undefined;
784
+ const timeout = p.InvocationTimeLimitSeconds;
785
+ if (typeof timeout !== "number") return undefined;
786
+ const invocationType = p.InvocationType;
787
+ if (invocationType !== "SYNCHRONOUS" && invocationType !== "ASYNCHRONOUS") return undefined;
788
+ const entries: [string, V][] = [
789
+ ["id", a.Identifier],
790
+ ["lambda", ref],
791
+ ["timeoutSeconds", timeout],
792
+ ];
793
+ if (invocationType === "ASYNCHRONOUS") entries.push(["invocationType", invocationType]);
794
+ const attributes = p.LambdaInvocationAttributes;
795
+ if (attributes !== undefined) {
796
+ if (!isStringMap(attributes)) return undefined;
797
+ entries.push(["attributes", toV(attributes)]);
798
+ }
799
+ let responseType: "STRING_MAP" | "JSON" | undefined;
800
+ if (p.ResponseValidation !== undefined) {
801
+ const rv = p.ResponseValidation as Record<string, unknown> | null;
802
+ if (rv === null || typeof rv !== "object" || !paramKeysAre(rv, ["ResponseType"])) {
803
+ return undefined;
804
+ }
805
+ if (rv.ResponseType !== "STRING_MAP" && rv.ResponseType !== "JSON") return undefined;
806
+ responseType = rv.ResponseType;
807
+ entries.push(["responseType", responseType]);
808
+ }
809
+ entries.push(["next", w.next], ["onError", w.onError]);
810
+ return {
811
+ cls: "InvokeLambdaFunction",
812
+ entries,
813
+ block: new InvokeLambdaFunction({
814
+ id: a.Identifier,
815
+ lambda: cast<never>(p.LambdaFunctionARN),
816
+ timeoutSeconds: timeout,
817
+ ...(invocationType === "ASYNCHRONOUS" ? { invocationType } : {}),
818
+ ...(attributes === undefined
819
+ ? {}
820
+ : { attributes: cast<Record<string, string>>(attributes) }),
821
+ ...(responseType === undefined ? {} : { responseType }),
822
+ next: w.next,
823
+ onError: w.onError,
824
+ }),
825
+ };
826
+ },
827
+ };
828
+
829
+ function invertGeneric(a: FlowAction): Inversion {
830
+ const entries: [string, V][] = [
831
+ ["id", a.Identifier],
832
+ ["type", a.Type],
833
+ ];
834
+ const cfg: GenericBlockConfig = { id: a.Identifier, type: a.Type };
835
+ // Connect omits Parameters and could omit Transitions on an action that has
836
+ // none, and a document read off disk is not guaranteed to have been through
837
+ // export's normalization. GenericBlock itself already defaults both, so
838
+ // matching it here keeps an unmodeled action degrading rather than throwing
839
+ // "Cannot convert undefined or null to object".
840
+ const parameters = a.Parameters ?? {};
841
+ if (Object.keys(parameters).length > 0) {
842
+ entries.push(["parameters", toV(parameters)]);
843
+ cfg.parameters = parameters;
844
+ }
845
+ const t = a.Transitions ?? {};
846
+ if (t.NextAction !== undefined) {
847
+ entries.push(["next", t.NextAction]);
848
+ cfg.next = t.NextAction;
849
+ }
850
+ const errors = t.Errors ?? [];
851
+ if (errors.length > 0) {
852
+ entries.push([
853
+ "errors",
854
+ new ArrV(
855
+ errors.map(
856
+ (e: ErrorTransition) =>
857
+ new ObjV([
858
+ ["errorType", e.ErrorType],
859
+ ["target", e.NextAction],
860
+ ]),
861
+ ),
862
+ ),
863
+ ]);
864
+ cfg.errors = errors.map((e) => ({ errorType: e.ErrorType, target: e.NextAction }));
865
+ }
866
+ const conditions = t.Conditions ?? [];
867
+ if (conditions.length > 0) {
868
+ entries.push([
869
+ "conditions",
870
+ new ArrV(
871
+ conditions.map(
872
+ (c) =>
873
+ new ObjV([
874
+ ["target", c.NextAction],
875
+ ["operator", c.Condition.Operator],
876
+ ["operands", new ArrV([...c.Condition.Operands])],
877
+ ]),
878
+ ),
879
+ ),
880
+ ]);
881
+ cfg.conditions = conditions.map((c) => ({
882
+ target: c.NextAction,
883
+ operator: c.Condition.Operator,
884
+ operands: c.Condition.Operands,
885
+ }));
886
+ }
887
+ return { cls: "GenericBlock", entries, block: new GenericBlock(cfg) };
888
+ }
889
+
890
+ /** Exact inversion or the GenericBlock fallback; never lossy. */
891
+ function invertAction(a: FlowAction, ctx: Ctx): Inversion {
892
+ const inverter = INVERTERS[a.Type];
893
+ if (inverter !== undefined) {
894
+ try {
895
+ const scratch: Ctx = { refs: false, jsonPath: false };
896
+ const candidate = inverter(a, scratch);
897
+ if (candidate !== undefined && stableJson(candidate.block.toAction()) === stableJson(a)) {
898
+ ctx.refs = ctx.refs || scratch.refs;
899
+ ctx.jsonPath = ctx.jsonPath || scratch.jsonPath;
900
+ return candidate;
901
+ }
902
+ } catch {
903
+ // Constructor validation rejected the shape; fall through to generic.
904
+ }
905
+ }
906
+ return invertGeneric(a);
907
+ }
908
+
909
+ // ---------------------------------------------------------------------------
910
+ // @keep comments
911
+ // ---------------------------------------------------------------------------
912
+
913
+ interface KeepComments {
914
+ beforeExport: string[];
915
+ beforeBlock: Map<string, string[]>;
916
+ }
917
+
918
+ const NEW_BLOCK = /^new [A-Za-z_$][A-Za-z0-9_$]*\(\{/;
919
+ const BLOCK_ID = /\bid: (["'])((?:[^"'\\]|\\.)*)\1/;
920
+
921
+ function extractKeepComments(previous: string): KeepComments {
922
+ const out: KeepComments = { beforeExport: [], beforeBlock: new Map() };
923
+ const lines = previous.split("\n");
924
+ let pending: string[] = [];
925
+ for (let i = 0; i < lines.length; i++) {
926
+ const trimmed = lines[i]!.trim();
927
+ if (trimmed.startsWith("//")) {
928
+ pending.push(trimmed);
929
+ continue;
930
+ }
931
+ const keeps = pending.filter((l) => l.includes("@keep"));
932
+ pending = [];
933
+ if (keeps.length === 0) continue;
934
+ if (trimmed.startsWith("export function ")) {
935
+ out.beforeExport = keeps;
936
+ } else if (NEW_BLOCK.test(trimmed)) {
937
+ // The id property is on this line or within the next few.
938
+ for (let j = i; j < Math.min(i + 5, lines.length); j++) {
939
+ const m = BLOCK_ID.exec(lines[j]!);
940
+ if (m !== null) {
941
+ out.beforeBlock.set(m[2]!, keeps);
942
+ break;
943
+ }
944
+ }
945
+ }
946
+ }
947
+ return out;
948
+ }
949
+
950
+ // ---------------------------------------------------------------------------
951
+ // Naming
952
+ // ---------------------------------------------------------------------------
953
+
954
+ const RESERVED_WORDS = new Set([
955
+ "await",
956
+ "break",
957
+ "case",
958
+ "catch",
959
+ "class",
960
+ "const",
961
+ "continue",
962
+ "debugger",
963
+ "default",
964
+ "delete",
965
+ "do",
966
+ "else",
967
+ "enum",
968
+ "export",
969
+ "extends",
970
+ "false",
971
+ "finally",
972
+ "for",
973
+ "function",
974
+ "if",
975
+ "implements",
976
+ "import",
977
+ "in",
978
+ "instanceof",
979
+ "interface",
980
+ "let",
981
+ "new",
982
+ "null",
983
+ "package",
984
+ "private",
985
+ "protected",
986
+ "public",
987
+ "return",
988
+ "static",
989
+ "super",
990
+ "switch",
991
+ "this",
992
+ "throw",
993
+ "true",
994
+ "try",
995
+ "typeof",
996
+ "var",
997
+ "void",
998
+ "while",
999
+ "with",
1000
+ "yield",
1001
+ ]);
1002
+
1003
+ /**
1004
+ * The exported factory's name: camelCase of the flow name, prefixed with
1005
+ * "flow" when it would start with a digit, hit a reserved word, or collide
1006
+ * with an identifier the file imports (a flow named "json-path" must not emit
1007
+ * `export function jsonPath` next to `import { jsonPath }`).
1008
+ */
1009
+ export function factoryName(name: string, taken: ReadonlySet<string> = new Set()): string {
1010
+ const parts = name.split(/[^A-Za-z0-9]+/).filter((p) => p.length > 0);
1011
+ if (parts.length === 0) {
1012
+ throw new Error(`Cannot derive a factory name from flow name "${name}".`);
1013
+ }
1014
+ let candidate = parts.map((p, i) => (i === 0 ? p : p[0]!.toUpperCase() + p.slice(1))).join("");
1015
+ while (/^[0-9]/.test(candidate) || RESERVED_WORDS.has(candidate) || taken.has(candidate)) {
1016
+ candidate = `flow${candidate[0]!.toUpperCase()}${candidate.slice(1)}`;
1017
+ }
1018
+ return candidate;
1019
+ }
1020
+
1021
+ // ---------------------------------------------------------------------------
1022
+ // Assembly
1023
+ // ---------------------------------------------------------------------------
1024
+
1025
+ function flowConfigEntries(doc: FlowDoc): [string, V][] {
1026
+ const entries: [string, V][] = [["name", doc.name]];
1027
+ if (doc.kind !== "module") entries.push(["connectType", doc.connectType]);
1028
+ // A module's empty Settings is synth's default, so it is not worth emitting;
1029
+ // a non-empty one must be, or synth would re-default it and the round trip
1030
+ // would silently lose module configuration.
1031
+ const settings = doc.content.Settings;
1032
+ if (doc.kind === "module" && settings !== undefined && Object.keys(settings).length > 0) {
1033
+ entries.push(["settings", toV(settings)]);
1034
+ }
1035
+ const first = doc.content.Actions[0];
1036
+ if (first !== undefined && doc.content.StartAction !== first.Identifier) {
1037
+ entries.push(["start", doc.content.StartAction]);
1038
+ }
1039
+ // Layout equal to deterministic auto-layout is a derivable default; only
1040
+ // hand-placed positions are worth carrying in source. Either way
1041
+ // synth(codegen(doc)) reproduces doc.layout.
1042
+ if (doc.layout !== undefined) {
1043
+ const auto = autoLayout(doc.content.Actions);
1044
+ if (stableJson(doc.layout) !== stableJson(auto)) {
1045
+ const layoutEntries = Object.keys(doc.layout)
1046
+ .sort()
1047
+ .map((id): [string, V] => {
1048
+ const p = doc.layout![id] as Point;
1049
+ return [
1050
+ id,
1051
+ new ObjV([
1052
+ ["x", p.x],
1053
+ ["y", p.y],
1054
+ ]),
1055
+ ];
1056
+ });
1057
+ entries.push(["layout", new ObjV(layoutEntries)]);
1058
+ }
1059
+ }
1060
+ return entries;
1061
+ }
1062
+
1063
+ function renderBlock(inv: Inversion, indent: string): string {
1064
+ const head = `new ${inv.cls}(`;
1065
+ if (inv.entries.length === 1) {
1066
+ const flat = inlineV(new ObjV(inv.entries));
1067
+ if (flat !== undefined && indent.length + head.length + flat.length + 2 <= PRINT_WIDTH) {
1068
+ return `${head}${flat})`;
1069
+ }
1070
+ }
1071
+ return `${head}${printBrokenObject(inv.entries, indent)})`;
1072
+ }
1073
+
1074
+ /** A block's single-line form, or undefined when it must break. */
1075
+ function inlineBlock(inv: Inversion): string | undefined {
1076
+ if (inv.entries.length !== 1) return undefined;
1077
+ const flat = inlineV(new ObjV(inv.entries));
1078
+ return flat === undefined ? undefined : `new ${inv.cls}(${flat})`;
1079
+ }
1080
+
1081
+ export function codegen(doc: FlowDoc, options: CodegenOptions = {}): string {
1082
+ assertFlowDoc(doc, "codegen");
1083
+ if (doc.kind === "module" && doc.connectType !== "MODULE") {
1084
+ throw new Error(`FlowDoc kind "module" requires connectType MODULE, got ${doc.connectType}.`);
1085
+ }
1086
+ if (doc.content.Actions.length === 0) {
1087
+ throw new Error(`Flow "${doc.name}" has no actions; nothing to generate.`);
1088
+ }
1089
+ const spec = options.moduleSpecifier ?? DEFAULT_MODULE_SPECIFIER;
1090
+ const keep =
1091
+ options.previous === undefined
1092
+ ? { beforeExport: [], beforeBlock: new Map<string, string[]>() }
1093
+ : extractKeepComments(options.previous);
1094
+
1095
+ const ctx: Ctx = { refs: false, jsonPath: false };
1096
+ const inversions = doc.content.Actions.map((a) => invertAction(a, ctx));
1097
+
1098
+ const flowClass = doc.kind === "module" ? "FlowModule" : "Flow";
1099
+ const imports = new Set<string>([flowClass, ...inversions.map((i) => i.cls)]);
1100
+ if (ctx.refs) imports.add("Refs");
1101
+ if (ctx.jsonPath) imports.add("jsonPath");
1102
+ const names = [...imports].sort();
1103
+
1104
+ const importInline = `import { ${names.join(", ")} } from ${quoteString(spec)};`;
1105
+ const importSource =
1106
+ importInline.length <= PRINT_WIDTH
1107
+ ? importInline
1108
+ : `import {\n${names.map((n) => ` ${n},`).join("\n")}\n} from ${quoteString(spec)};`;
1109
+
1110
+ const fn = factoryName(doc.name, imports);
1111
+ const cfgEntries = flowConfigEntries(doc);
1112
+
1113
+ // The whole return statement collapses to one line when Prettier would:
1114
+ // every argument fits inline and the line stays within the print width.
1115
+ const cfgInline = inlineV(new ObjV(cfgEntries));
1116
+ const argInlines = inversions.map(inlineBlock);
1117
+ const hasBlockComments = inversions.some((i) => keep.beforeBlock.has(i.block.id));
1118
+ let body: string;
1119
+ const oneLine =
1120
+ cfgInline === undefined || argInlines.some((a) => a === undefined)
1121
+ ? undefined
1122
+ : ` return new ${flowClass}(${cfgInline}).add(${argInlines.join(", ")});`;
1123
+ if (oneLine !== undefined && oneLine.length <= PRINT_WIDTH && !hasBlockComments) {
1124
+ body = oneLine;
1125
+ } else {
1126
+ const openInline =
1127
+ cfgInline === undefined ? undefined : ` return new ${flowClass}(${cfgInline}).add(`;
1128
+ const open =
1129
+ openInline !== undefined && openInline.length <= PRINT_WIDTH
1130
+ ? openInline
1131
+ : ` return new ${flowClass}(${printBrokenObject(cfgEntries, " ")}).add(`;
1132
+ const args: string[] = [];
1133
+ for (const inv of inversions) {
1134
+ for (const c of keep.beforeBlock.get(inv.block.id) ?? []) args.push(` ${c}`);
1135
+ args.push(` ${renderBlock(inv, " ")},`);
1136
+ }
1137
+ body = `${open}\n${args.join("\n")}\n );`;
1138
+ }
1139
+
1140
+ const lines: string[] = [...generatedMarker(doc.name), "", importSource, ""];
1141
+ for (const c of keep.beforeExport) lines.push(c);
1142
+ lines.push(`export function ${fn}(): ${flowClass} {`, body, "}");
1143
+ return lines.join("\n") + "\n";
1144
+ }