@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/flow.ts ADDED
@@ -0,0 +1,108 @@
1
+ /*
2
+ * Copyright 2026 The flow-as-code Authors
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ // Flow and FlowModule: an ordered set of blocks plus the metadata that says
6
+ // what kind of Connect artifact they become.
7
+
8
+ import { Block, targetId } from "./blocks.js";
9
+ import type { ConnectType } from "./flowdoc.js";
10
+ import { InvalidFlowDocError, MAX_ACTIONS_PER_FLOW, SLUG_PATTERN } from "./flowdoc.js";
11
+ import type { Point } from "./flowdoc.js";
12
+
13
+ export interface FlowConfig {
14
+ name: string;
15
+ connectType?: ConnectType;
16
+ /** Defaults to the first block added. */
17
+ start?: string | Block;
18
+ /** Hand-placed positions. Anything omitted is auto-laid-out at synth time. */
19
+ layout?: Record<string, Point>;
20
+ }
21
+
22
+ export class Flow {
23
+ readonly name: string;
24
+ readonly connectType: ConnectType;
25
+ readonly kind: "flow" | "module" = "flow";
26
+ readonly layout: Record<string, Point>;
27
+ private readonly blocks: Block[] = [];
28
+ private readonly explicitStart?: string;
29
+
30
+ constructor(config: FlowConfig) {
31
+ // Names are slugs (the FlowDoc schema requires it), and they become file
32
+ // names downstream (`<name>.flowdoc.json`), so anything else, including a
33
+ // path fragment, is rejected here rather than at write time.
34
+ if (!SLUG_PATTERN.test(config.name)) {
35
+ throw new Error(
36
+ `Invalid flow name "${config.name}". Names must be lowercase words separated by single hyphens.`,
37
+ );
38
+ }
39
+ this.name = config.name;
40
+ this.connectType = config.connectType ?? "CONTACT_FLOW";
41
+ this.layout = config.layout ?? {};
42
+ if (config.start !== undefined) this.explicitStart = targetId(config.start);
43
+ }
44
+
45
+ /** Adds blocks in declaration order. Returns this for chaining. */
46
+ add(...blocks: Block[]): this {
47
+ for (const b of blocks) {
48
+ if (this.blocks.some((existing) => existing.id === b.id)) {
49
+ throw new Error(`Duplicate Identifier "${b.id}" in flow "${this.name}".`);
50
+ }
51
+ this.blocks.push(b);
52
+ }
53
+ if (this.blocks.length > MAX_ACTIONS_PER_FLOW) {
54
+ throw new Error(
55
+ `Flow "${this.name}" has ${this.blocks.length} actions; Connect allows at most ${MAX_ACTIONS_PER_FLOW}.`,
56
+ );
57
+ }
58
+ return this;
59
+ }
60
+
61
+ all(): readonly Block[] {
62
+ return this.blocks;
63
+ }
64
+
65
+ startId(): string {
66
+ if (this.explicitStart !== undefined) return this.explicitStart;
67
+ const first = this.blocks[0];
68
+ if (first === undefined) throw new Error(`Flow "${this.name}" has no blocks.`);
69
+ return first.id;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Structural check that a value can be treated as a Flow, run at the entry
75
+ * points that take one. Structural rather than `instanceof` so a Flow built
76
+ * against a second copy of the package (a workspace link, two versions in a
77
+ * dependency tree) is still accepted.
78
+ */
79
+ export function assertFlow(value: unknown, context: string): asserts value is Flow {
80
+ const flow = value as Partial<Flow> | null | undefined;
81
+ const ok =
82
+ typeof flow === "object" &&
83
+ flow !== null &&
84
+ typeof flow.all === "function" &&
85
+ typeof flow.startId === "function" &&
86
+ typeof flow.name === "string" &&
87
+ typeof flow.connectType === "string" &&
88
+ typeof flow.layout === "object" &&
89
+ flow.layout !== null;
90
+ if (!ok) {
91
+ const got = value === null ? "null" : Array.isArray(value) ? "an array" : `a ${typeof value}`;
92
+ throw new InvalidFlowDocError(
93
+ `${context} expects a Flow built with new Flow(...) or new FlowModule(...), got ${got}.`,
94
+ );
95
+ }
96
+ }
97
+
98
+ /** A reusable section of a flow. Materializes as a ContactFlowModule. */
99
+ export class FlowModule extends Flow {
100
+ override readonly kind = "module" as const;
101
+ /** Module configuration, emitted as content.Settings. Empty by default. */
102
+ readonly settings: Record<string, unknown>;
103
+
104
+ constructor(config: Omit<FlowConfig, "connectType"> & { settings?: Record<string, unknown> }) {
105
+ super({ ...config, connectType: "MODULE" });
106
+ this.settings = config.settings ?? {};
107
+ }
108
+ }
package/src/flowdoc.ts ADDED
@@ -0,0 +1,261 @@
1
+ /*
2
+ * Copyright 2026 The flow-as-code Authors
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ // FlowDoc: the single interchange format. See docs/01-flowdoc-spec.md and
6
+ // conformance/schema/flowdoc-0.1.schema.json, which is the normative form of
7
+ // these types.
8
+
9
+ export const FLOWDOC_VERSION = "0.1";
10
+
11
+ /** The only Flow language version Connect supports. */
12
+ export const FLOW_LANGUAGE_VERSION = "2019-10-30";
13
+
14
+ /** A single flow holds no more than 250 Actions. */
15
+ export const MAX_ACTIONS_PER_FLOW = 250;
16
+
17
+ /** Identifiers are unique within a flow and at most 50 characters. */
18
+ export const MAX_IDENTIFIER_LENGTH = 50;
19
+
20
+ /**
21
+ * Characters Connect rejects in an Identifier.
22
+ * https://docs.aws.amazon.com/connect/latest/devguide/flow-language-actions.html
23
+ */
24
+ export const FORBIDDEN_IDENTIFIER_CHARS = [
25
+ "%",
26
+ ":",
27
+ "(",
28
+ "\\",
29
+ "/",
30
+ ")",
31
+ "=",
32
+ "$",
33
+ ",",
34
+ ";",
35
+ "[",
36
+ "]",
37
+ "{",
38
+ "}",
39
+ ] as const;
40
+
41
+ /** Identifier values Connect rejects outright (prototype pollution names). */
42
+ export const FORBIDDEN_IDENTIFIERS = [
43
+ "__proto__",
44
+ "constructor",
45
+ "__defineGetter__",
46
+ "__defineSetter__",
47
+ "toString",
48
+ "hasOwnProperty",
49
+ "isPrototypeOf",
50
+ "propertyIsEnumerable",
51
+ "toLocaleString",
52
+ "valueOf",
53
+ ] as const;
54
+
55
+ export type ConnectType =
56
+ | "CONTACT_FLOW"
57
+ | "CUSTOMER_QUEUE"
58
+ | "CUSTOMER_HOLD"
59
+ | "CUSTOMER_WHISPER"
60
+ | "AGENT_HOLD"
61
+ | "AGENT_WHISPER"
62
+ | "OUTBOUND_WHISPER"
63
+ | "AGENT_TRANSFER"
64
+ | "QUEUE_TRANSFER"
65
+ | "MODULE";
66
+
67
+ export type ConditionOperator =
68
+ | "Equals"
69
+ | "TextStartsWith"
70
+ | "TextEndsWith"
71
+ | "TextContains"
72
+ | "NumberGreaterThan"
73
+ | "NumberGreaterOrEqualTo"
74
+ | "NumberLessThan"
75
+ | "NumberLessOrEqualTo";
76
+
77
+ export interface Condition {
78
+ Operator: ConditionOperator;
79
+ Operands: string[];
80
+ }
81
+
82
+ export interface ErrorTransition {
83
+ ErrorType: string;
84
+ NextAction: string;
85
+ }
86
+
87
+ export interface ConditionTransition {
88
+ NextAction: string;
89
+ Condition: Condition;
90
+ }
91
+
92
+ export interface Transitions {
93
+ NextAction?: string;
94
+ Errors?: ErrorTransition[];
95
+ Conditions?: ConditionTransition[];
96
+ }
97
+
98
+ export interface FlowAction {
99
+ Identifier: string;
100
+ Type: string;
101
+ Parameters: Record<string, unknown>;
102
+ Transitions: Transitions;
103
+ }
104
+
105
+ export interface FlowContent {
106
+ Version: typeof FLOW_LANGUAGE_VERSION;
107
+ StartAction: string;
108
+ /**
109
+ * Not authored. Materialization projects `layout` into this so the flow lays
110
+ * out correctly in the Connect console. See docs/01-flowdoc-spec.md.
111
+ */
112
+ Metadata?: Record<string, unknown>;
113
+ /**
114
+ * Module configuration. Connect requires a top-level `Settings` object in a
115
+ * flow MODULE's content and rejects CreateContactFlowModule without it
116
+ * ("JSON field is missing or null for field name: settings"); a regular flow
117
+ * has no Settings. Empty is valid and is what the console stores for a module
118
+ * with no special configuration. Verified against a live instance 2026-09-01.
119
+ */
120
+ Settings?: Record<string, unknown>;
121
+ Actions: FlowAction[];
122
+ }
123
+
124
+ export interface Point {
125
+ x: number;
126
+ y: number;
127
+ }
128
+
129
+ export type RefType = "queue" | "hours" | "lambda" | "lex" | "prompt" | "flow" | "module";
130
+
131
+ export interface RefEntry {
132
+ token: string;
133
+ type: RefType;
134
+ name: string;
135
+ alias?: string;
136
+ }
137
+
138
+ export interface FlowDocMeta {
139
+ generator?: string;
140
+ sourceHash?: string;
141
+ [key: string]: unknown;
142
+ }
143
+
144
+ export interface FlowDoc {
145
+ flowdoc: typeof FLOWDOC_VERSION;
146
+ kind: "flow" | "module";
147
+ name: string;
148
+ connectType: ConnectType;
149
+ content: FlowContent;
150
+ layout?: Record<string, Point>;
151
+ refs?: RefEntry[];
152
+ meta?: FlowDocMeta;
153
+ }
154
+
155
+ /** Names are slugs: lowercase, digits, single hyphens. */
156
+ export const SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
157
+
158
+ export function isValidIdentifier(id: string): boolean {
159
+ if (id.length === 0 || id.length > MAX_IDENTIFIER_LENGTH) return false;
160
+ if ((FORBIDDEN_IDENTIFIERS as readonly string[]).includes(id)) return false;
161
+ return !FORBIDDEN_IDENTIFIER_CHARS.some((c) => id.includes(c));
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Entry-point guard
166
+ // ---------------------------------------------------------------------------
167
+
168
+ /**
169
+ * A public entry point was handed something that is not a usable FlowDoc.
170
+ *
171
+ * Without this, the first property access decided the message, so calling
172
+ * `codegen(undefined)` or linting a document with no `content` reported
173
+ * "Cannot read properties of undefined (reading 'Actions')", which names
174
+ * neither the entry point, the argument, nor what was wrong with it.
175
+ */
176
+ export class InvalidFlowDocError extends Error {
177
+ constructor(message: string) {
178
+ super(message);
179
+ this.name = "InvalidFlowDocError";
180
+ }
181
+ }
182
+
183
+ function typeName(value: unknown): string {
184
+ if (value === null) return "null";
185
+ if (value === undefined) return "undefined";
186
+ if (Array.isArray(value)) return "an array";
187
+ if (typeof value === "string") return `a string (${JSON.stringify(value)})`;
188
+ return `a ${typeof value}`;
189
+ }
190
+
191
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
192
+ return typeof value === "object" && value !== null && !Array.isArray(value);
193
+ }
194
+
195
+ /**
196
+ * Structural check that a value can be treated as a FlowDoc, run at every
197
+ * public entry point that reads one.
198
+ *
199
+ * Deliberately shallow: it checks the shape the library dereferences without
200
+ * asking, not the FlowDoc schema. Full validation is
201
+ * conformance/schema/flowdoc-0.1.schema.json, which `flow-cli lint` runs;
202
+ * doing it here would make every entry point pay for an Ajv compile and would
203
+ * reject documents the studio legitimately holds mid-edit.
204
+ *
205
+ * @param context the entry point name, so the message says who was called.
206
+ */
207
+ export function assertFlowDoc(value: unknown, context: string): asserts value is FlowDoc {
208
+ if (!isPlainObject(value)) {
209
+ throw new InvalidFlowDocError(`${context} expects a FlowDoc object, got ${typeName(value)}.`);
210
+ }
211
+ for (const key of ["name", "kind", "connectType"] as const) {
212
+ if (typeof value[key] !== "string") {
213
+ throw new InvalidFlowDocError(
214
+ `${context} expects a FlowDoc with a string "${key}", got ${typeName(value[key])}.`,
215
+ );
216
+ }
217
+ }
218
+ const content = value.content;
219
+ if (!isPlainObject(content)) {
220
+ throw new InvalidFlowDocError(
221
+ `${context} expects a FlowDoc with an object "content", got ${typeName(content)}.`,
222
+ );
223
+ }
224
+ if (typeof content.StartAction !== "string") {
225
+ throw new InvalidFlowDocError(
226
+ `${context} expects a FlowDoc with a string "content.StartAction", got ${typeName(
227
+ content.StartAction,
228
+ )}.`,
229
+ );
230
+ }
231
+ const actions = content.Actions;
232
+ if (!Array.isArray(actions)) {
233
+ throw new InvalidFlowDocError(
234
+ `${context} expects a FlowDoc with an array "content.Actions", got ${typeName(actions)}.`,
235
+ );
236
+ }
237
+ for (const [i, action] of actions.entries()) {
238
+ const at = `${context}: FlowDoc content.Actions[${String(i)}]`;
239
+ if (!isPlainObject(action)) {
240
+ throw new InvalidFlowDocError(`${at} must be an action object, got ${typeName(action)}.`);
241
+ }
242
+ for (const key of ["Identifier", "Type"] as const) {
243
+ if (typeof action[key] !== "string") {
244
+ throw new InvalidFlowDocError(
245
+ `${at} must have a string "${key}", got ${typeName(action[key])}.`,
246
+ );
247
+ }
248
+ }
249
+ // Parameters and Transitions are dereferenced without a guard by codegen,
250
+ // materialization, and several lint rules.
251
+ for (const key of ["Parameters", "Transitions"] as const) {
252
+ if (!isPlainObject(action[key])) {
253
+ throw new InvalidFlowDocError(
254
+ `${at} ("${String(action.Identifier)}") must have an object "${key}", got ${typeName(
255
+ action[key],
256
+ )}.`,
257
+ );
258
+ }
259
+ }
260
+ }
261
+ }
package/src/index.ts ADDED
@@ -0,0 +1,31 @@
1
+ /*
2
+ * Copyright 2026 The flow-as-code Authors
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ // Public surface of @flow-as-code/core. See SPEC.md and docs/01-flowdoc-spec.md.
6
+ //
7
+ // Implemented: FlowDoc types, Refs, builder blocks, deterministic
8
+ // serialization, dagre auto-layout, synth, lint, codegen, materialization,
9
+ // export from a live instance, and the simulate scenario format, runner, and
10
+ // reporters. Watch (A04) lives in @flow-as-code/cli.
11
+
12
+ export * from "./flowdoc.js";
13
+ export * from "./refs.js";
14
+ export * from "./actions.js";
15
+ export * from "./blocks.js";
16
+ export * from "./flow.js";
17
+ export * from "./synth.js";
18
+ export * from "./codegen.js";
19
+ export * from "./package-names.js";
20
+ export { autoLayout, NODE_WIDTH, NODE_HEIGHT } from "./layout.js";
21
+ export { canonicalize, serialize } from "./serialize.js";
22
+ export {
23
+ MaterializeError,
24
+ materializeWithBinder,
25
+ materializeWithMap,
26
+ serializeContent,
27
+ } from "./materialize.js";
28
+ export * from "./aws.js";
29
+ export * from "./export.js";
30
+ export * from "./simulate.js";
31
+ export * from "./lint/index.js";
package/src/layout.ts ADDED
@@ -0,0 +1,54 @@
1
+ /*
2
+ * Copyright 2026 The flow-as-code Authors
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ // Deterministic auto-layout. The studio persists user positions; synth fills in
6
+ // positions only for actions that have none, so hand-placed nodes survive.
7
+
8
+ import dagre from "dagre";
9
+ import type { FlowAction, Point } from "./flowdoc.js";
10
+
11
+ /** Node box used for layout. Matches the studio's default node size. */
12
+ export const NODE_WIDTH = 180;
13
+ export const NODE_HEIGHT = 60;
14
+
15
+ function transitionTargets(action: FlowAction): string[] {
16
+ const t = action.Transitions;
17
+ return [
18
+ ...(t.NextAction === undefined ? [] : [t.NextAction]),
19
+ ...(t.Errors ?? []).map((e) => e.NextAction),
20
+ ...(t.Conditions ?? []).map((c) => c.NextAction),
21
+ ];
22
+ }
23
+
24
+ /**
25
+ * Left-to-right dagre layout. Deterministic for a given action list: dagre is
26
+ * seeded only by insertion order, and actions are already in canonical order by
27
+ * the time this runs.
28
+ */
29
+ export function autoLayout(actions: FlowAction[]): Record<string, Point> {
30
+ const g = new dagre.graphlib.Graph();
31
+ g.setGraph({ rankdir: "LR", nodesep: 40, ranksep: 80, marginx: 20, marginy: 20 });
32
+ g.setDefaultEdgeLabel(() => ({}));
33
+
34
+ const ids = new Set(actions.map((a) => a.Identifier));
35
+ for (const a of actions) g.setNode(a.Identifier, { width: NODE_WIDTH, height: NODE_HEIGHT });
36
+ for (const a of actions) {
37
+ for (const target of transitionTargets(a)) {
38
+ // Dangling targets are a lint finding, not a layout crash.
39
+ if (ids.has(target)) g.setEdge(a.Identifier, target);
40
+ }
41
+ }
42
+
43
+ dagre.layout(g);
44
+
45
+ const positions: Record<string, Point> = {};
46
+ for (const a of actions) {
47
+ const node = g.node(a.Identifier) as { x: number; y: number } | undefined;
48
+ // dagre reports centres; FlowDoc stores top-left, as the console does.
49
+ positions[a.Identifier] = node
50
+ ? { x: Math.round(node.x - NODE_WIDTH / 2), y: Math.round(node.y - NODE_HEIGHT / 2) }
51
+ : { x: 0, y: 0 };
52
+ }
53
+ return positions;
54
+ }
@@ -0,0 +1,56 @@
1
+ /*
2
+ * Copyright 2026 The flow-as-code Authors
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ import type { FlowDoc } from "../flowdoc.js";
6
+ import { assertFlowDoc } from "../flowdoc.js";
7
+ import { allRules } from "./rules/index.js";
8
+ import type { Finding, Rule } from "./types.js";
9
+
10
+ export interface LintOptions {
11
+ /** Defaults to every built-in rule. */
12
+ rules?: readonly Rule[];
13
+ /** Rule ids to skip. */
14
+ disable?: readonly string[];
15
+ }
16
+
17
+ const order = (a: Finding, b: Finding): number =>
18
+ a.doc.localeCompare(b.doc) ||
19
+ a.rule.localeCompare(b.rule) ||
20
+ (a.blockId ?? "").localeCompare(b.blockId ?? "") ||
21
+ a.message.localeCompare(b.message);
22
+
23
+ /**
24
+ * Runs every rule over every document. Findings are sorted so output is stable
25
+ * regardless of rule execution order.
26
+ */
27
+ export function lint(input: FlowDoc | readonly FlowDoc[], options: LintOptions = {}): Finding[] {
28
+ const docs = Array.isArray(input) ? input : [input as FlowDoc];
29
+ // Rules read doc.content.Actions and doc.name directly. A malformed document
30
+ // used to surface as a TypeError from whichever rule happened to run first,
31
+ // which named neither lint nor the document.
32
+ for (const doc of docs) assertFlowDoc(doc, "lint");
33
+ const disabled = new Set(options.disable ?? []);
34
+ const rules = (options.rules ?? allRules).filter((r) => !disabled.has(r.id));
35
+
36
+ const findings: Finding[] = [];
37
+ for (const doc of docs) {
38
+ for (const rule of rules) {
39
+ rule.check({
40
+ doc,
41
+ all: docs,
42
+ report: (f) => findings.push({ ...f, rule: rule.id, doc: doc.name }),
43
+ });
44
+ }
45
+ }
46
+ return findings.sort(order);
47
+ }
48
+
49
+ /** True when any finding would block a studio save. */
50
+ export function hasBlockingFindings(
51
+ findings: readonly Finding[],
52
+ rules: readonly Rule[] = allRules,
53
+ ): boolean {
54
+ const hard = new Set(rules.filter((r) => r.hard).map((r) => r.id));
55
+ return findings.some((f) => hard.has(f.rule));
56
+ }
@@ -0,0 +1,86 @@
1
+ /*
2
+ * Copyright 2026 The flow-as-code Authors
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ // Shared traversal helpers. Browser-safe: no Node builtins.
6
+
7
+ import type { FlowAction, FlowDoc } from "../flowdoc.js";
8
+
9
+ export function transitionTargets(action: FlowAction): string[] {
10
+ const t = action.Transitions;
11
+ return [
12
+ ...(t.NextAction === undefined ? [] : [t.NextAction]),
13
+ ...(t.Errors ?? []).map((e) => e.NextAction),
14
+ ...(t.Conditions ?? []).map((c) => c.NextAction),
15
+ ];
16
+ }
17
+
18
+ export function isTerminal(action: FlowAction): boolean {
19
+ return Object.keys(action.Transitions).length === 0;
20
+ }
21
+
22
+ export function actionsById(doc: FlowDoc): Map<string, FlowAction> {
23
+ return new Map(doc.content.Actions.map((a) => [a.Identifier, a]));
24
+ }
25
+
26
+ /** Identifiers reachable from StartAction. */
27
+ export function reachable(doc: FlowDoc): Set<string> {
28
+ const byId = actionsById(doc);
29
+ const seen = new Set<string>();
30
+ const queue = [doc.content.StartAction];
31
+ while (queue.length > 0) {
32
+ const id = queue.shift()!;
33
+ if (seen.has(id)) continue;
34
+ const action = byId.get(id);
35
+ if (action === undefined) continue;
36
+ seen.add(id);
37
+ queue.push(...transitionTargets(action));
38
+ }
39
+ return seen;
40
+ }
41
+
42
+ /** Walks every string in a value, yielding [jsonPath, value] pairs. */
43
+ export function walkStrings(value: unknown, path = ""): [string, string][] {
44
+ if (typeof value === "string") return [[path, value]];
45
+ if (Array.isArray(value)) return value.flatMap((v, i) => walkStrings(v, `${path}[${i}]`));
46
+ if (value !== null && typeof value === "object") {
47
+ return Object.entries(value).flatMap(([k, v]) =>
48
+ walkStrings(v, path === "" ? k : `${path}.${k}`),
49
+ );
50
+ }
51
+ return [];
52
+ }
53
+
54
+ /**
55
+ * Every authored string in an action, with the path it sits at, for attributing
56
+ * a finding to a block.
57
+ *
58
+ * Transitions are included, not just Parameters. A condition operand is
59
+ * authored content like any other, and scanning only Parameters left a literal
60
+ * ARN or a malformed token in `Transitions.Conditions[n].Condition.Operands`
61
+ * completely undetected. Identifiers appearing here cannot produce false
62
+ * positives: Connect forbids ":" in an Identifier, so a NextAction can never
63
+ * look like an ARN or a token.
64
+ */
65
+ /**
66
+ * Authored strings that belong to the document rather than to any one action:
67
+ * today that is `content.Metadata`.
68
+ *
69
+ * materialize carries Metadata into deployable output and resolves tokens in
70
+ * it, so it is authored content by any reasonable reading, but nothing scanned
71
+ * it. A literal ARN placed there passed every gate and reached both deploy
72
+ * paths, including @flow-as-code/cdk, which refuses the identical ARN in Parameters.
73
+ */
74
+ export function documentStrings(doc: FlowDoc): [string, string][] {
75
+ if (doc.content.Metadata === undefined) return [];
76
+ return walkStrings(doc.content.Metadata, "content.Metadata");
77
+ }
78
+
79
+ export function findingsForActions(
80
+ doc: FlowDoc,
81
+ ): { action: FlowAction; strings: [string, string][] }[] {
82
+ return doc.content.Actions.map((action) => ({
83
+ action,
84
+ strings: [...walkStrings(action.Parameters), ...walkStrings(action.Transitions, "Transitions")],
85
+ }));
86
+ }
@@ -0,0 +1,10 @@
1
+ /*
2
+ * Copyright 2026 The flow-as-code Authors
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ export type { Finding, Rule, RuleContext, Severity } from "./types.js";
6
+ export { lint, hasBlockingFindings } from "./engine.js";
7
+ export type { LintOptions } from "./engine.js";
8
+ export { toJson, toText } from "./reporters.js";
9
+ export { allRules, ruleById } from "./rules/index.js";
10
+ export { NO_LITERAL_ARN, literalArnMessage, literalArnPaths } from "./rules/index.js";
@@ -0,0 +1,44 @@
1
+ /*
2
+ * Copyright 2026 The flow-as-code Authors
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ import type { Finding } from "./types.js";
6
+
7
+ /** Machine-readable. Stable shape; the CLI's --format json emits this. */
8
+ export function toJson(findings: readonly Finding[]): string {
9
+ const errors = findings.filter((f) => f.severity === "error").length;
10
+ return (
11
+ JSON.stringify(
12
+ {
13
+ summary: { total: findings.length, errors, warnings: findings.length - errors },
14
+ findings,
15
+ },
16
+ null,
17
+ 2,
18
+ ) + "\n"
19
+ );
20
+ }
21
+
22
+ /** Human-readable, one finding per line, grouped by document. */
23
+ export function toText(findings: readonly Finding[]): string {
24
+ if (findings.length === 0) return "No findings.\n";
25
+
26
+ const lines: string[] = [];
27
+ let current = "";
28
+ for (const f of findings) {
29
+ if (f.doc !== current) {
30
+ if (current !== "") lines.push("");
31
+ lines.push(f.doc);
32
+ current = f.doc;
33
+ }
34
+ const where = f.blockId === undefined ? "" : ` (${f.blockId})`;
35
+ lines.push(` ${f.severity}${where}: ${f.message} [${f.rule}]`);
36
+ }
37
+
38
+ const errors = findings.filter((f) => f.severity === "error").length;
39
+ lines.push("");
40
+ lines.push(
41
+ `${findings.length} finding(s): ${errors} error(s), ${findings.length - errors} warning(s).`,
42
+ );
43
+ return lines.join("\n") + "\n";
44
+ }
@@ -0,0 +1,32 @@
1
+ /*
2
+ * Copyright 2026 The flow-as-code Authors
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ import { FLOW_TYPE_RESTRICTIONS } from "../../actions.js";
6
+ import type { Rule } from "../types.js";
7
+
8
+ /**
9
+ * Almost every Connect action documents a Restrictions section naming the flow
10
+ * types it is legal in. Deploying a flow that violates one fails at create time
11
+ * with a message that does not say which block is at fault.
12
+ *
13
+ * Added 2026-08-31 from the A00b reference; not one of the original nine rules
14
+ * in SPEC.md. Restrictions live in actions.ts, sourced per action page.
15
+ */
16
+ export const actionAllowedInFlowType: Rule = {
17
+ id: "action-allowed-in-flow-type",
18
+ description: "Each action must be legal in the flow type that contains it.",
19
+ check({ doc, report }) {
20
+ for (const action of doc.content.Actions) {
21
+ const allowed = FLOW_TYPE_RESTRICTIONS[action.Type];
22
+ if (allowed === undefined) continue;
23
+ if (!allowed.includes(doc.connectType)) {
24
+ report({
25
+ severity: "error",
26
+ blockId: action.Identifier,
27
+ message: `${action.Type} is not allowed in a ${doc.connectType} flow. Allowed: ${allowed.join(", ")}.`,
28
+ });
29
+ }
30
+ }
31
+ },
32
+ };