@whitewall/blip-sdk 0.0.192 → 0.0.194

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 (39) hide show
  1. package/dist/cjs/index.js +1 -0
  2. package/dist/cjs/index.js.map +1 -1
  3. package/dist/cjs/types/flow.js +34 -1
  4. package/dist/cjs/types/flow.js.map +1 -1
  5. package/dist/cjs/utils/builder.js +9 -28
  6. package/dist/cjs/utils/builder.js.map +1 -1
  7. package/dist/cjs/utils/flow-rules.js +304 -0
  8. package/dist/cjs/utils/flow-rules.js.map +1 -0
  9. package/dist/cjs/utils/minifier.js +42 -92
  10. package/dist/cjs/utils/minifier.js.map +1 -1
  11. package/dist/esm/index.js +1 -0
  12. package/dist/esm/index.js.map +1 -1
  13. package/dist/esm/types/flow.js +29 -0
  14. package/dist/esm/types/flow.js.map +1 -1
  15. package/dist/esm/utils/builder.js +9 -27
  16. package/dist/esm/utils/builder.js.map +1 -1
  17. package/dist/esm/utils/flow-rules.js +297 -0
  18. package/dist/esm/utils/flow-rules.js.map +1 -0
  19. package/dist/esm/utils/minifier.js +40 -90
  20. package/dist/esm/utils/minifier.js.map +1 -1
  21. package/dist/types/index.d.ts +1 -0
  22. package/dist/types/index.d.ts.map +1 -1
  23. package/dist/types/types/account.d.ts +7 -0
  24. package/dist/types/types/account.d.ts.map +1 -1
  25. package/dist/types/types/flow.d.ts +33 -0
  26. package/dist/types/types/flow.d.ts.map +1 -1
  27. package/dist/types/utils/builder.d.ts +16 -25
  28. package/dist/types/utils/builder.d.ts.map +1 -1
  29. package/dist/types/utils/flow-rules.d.ts +50 -0
  30. package/dist/types/utils/flow-rules.d.ts.map +1 -0
  31. package/dist/types/utils/minifier.d.ts +294 -2
  32. package/dist/types/utils/minifier.d.ts.map +1 -1
  33. package/package.json +1 -1
  34. package/src/index.ts +1 -0
  35. package/src/types/account.ts +7 -0
  36. package/src/types/flow.ts +40 -0
  37. package/src/utils/builder.ts +29 -62
  38. package/src/utils/flow-rules.ts +453 -0
  39. package/src/utils/minifier.ts +46 -95
@@ -3,19 +3,20 @@
3
3
  * a human or an LLM. It renames the `$`-prefixed keys to readable ones, drops
4
4
  * layout metadata and denormalizes state names, so it cannot be pushed back to
5
5
  * the builder. For the round-trippable format, see `./minifier.ts`.
6
+ *
7
+ * Typed over `MinifiedState`, which a full `State` satisfies, so both a pulled
8
+ * flow and a minified one digest without casts.
6
9
  */
7
- import { type Action, type ContentActionItem, ROOT_STATE_ID, type State } from '../types/flow.ts'
10
+ import { type Condition, isChatStateSendMessage, resolveEntryPointId } from '../types/flow.ts'
11
+ import type { MinifiedAction, MinifiedInput, MinifiedState } from './minifier.ts'
12
+
13
+ /** A `Condition` with the builder's nullish bookkeeping dropped. */
14
+ export type ConditionDigest = Omit<Condition, 'variable'> & { variable?: string }
8
15
 
9
16
  export type ActionDigest = {
10
17
  type: string
11
18
  settings: Record<string, unknown>
12
- conditions?: Array<{
13
- source: string
14
- variable?: string
15
- comparison: string
16
- operator?: string
17
- values: Array<string>
18
- }>
19
+ conditions?: Array<ConditionDigest>
19
20
  }
20
21
 
21
22
  export type InputDigest = {
@@ -23,14 +24,14 @@ export type InputDigest = {
23
24
  variable?: string
24
25
  validation?: { rule: string; regex?: string; type?: string; error: string }
25
26
  expiration?: string
26
- conditions?: ActionDigest['conditions']
27
+ conditions?: Array<ConditionDigest>
27
28
  }
28
29
 
29
30
  export type OutputDigest = {
30
31
  stateId: string
31
32
  stateName?: string
32
33
  typeOfStateId?: string
33
- conditions?: ActionDigest['conditions']
34
+ conditions?: Array<ConditionDigest>
34
35
  }
35
36
 
36
37
  export type StateDigest = {
@@ -41,20 +42,12 @@ export type StateDigest = {
41
42
  contentActions?: Array<{ action?: ActionDigest; input?: InputDigest }>
42
43
  leavingActions?: Array<ActionDigest>
43
44
  outputs?: Array<OutputDigest>
44
- defaultOutput: { stateId: string; stateName?: string; typeOfStateId?: string }
45
+ defaultOutput: OutputDigest
45
46
  afterStateChangedActions?: Array<ActionDigest>
46
47
  localActions?: Array<ActionDigest>
47
48
  }
48
49
 
49
- function digestConditions(
50
- conditions?: Array<{
51
- source: string
52
- variable?: string
53
- comparison: string
54
- operator?: string
55
- values: Array<string>
56
- }>,
57
- ): ActionDigest['conditions'] | undefined {
50
+ function digestConditions(conditions?: Array<Condition>): Array<ConditionDigest> | undefined {
58
51
  if (!conditions || conditions.length === 0) return undefined
59
52
  return conditions.map((c) => ({
60
53
  source: c.source,
@@ -72,17 +65,17 @@ function digestActionSettings(settings: Record<string, unknown>): Record<string,
72
65
  return clone
73
66
  }
74
67
 
75
- export function digestAction(action: Action): ActionDigest {
68
+ export function digestAction(action: MinifiedAction): ActionDigest {
76
69
  const result: ActionDigest = {
77
70
  type: action.type,
78
71
  settings: digestActionSettings(action.settings as Record<string, unknown>),
79
72
  }
80
- const conditions = digestConditions(action.conditions as ActionDigest['conditions'])
73
+ const conditions = digestConditions(action.conditions)
81
74
  if (conditions) result.conditions = conditions
82
75
  return result
83
76
  }
84
77
 
85
- function digestInput(input: ContentActionItem['input']): InputDigest | undefined {
78
+ function digestInput(input: MinifiedInput | undefined): InputDigest | undefined {
86
79
  if (!input) return undefined
87
80
  const result: InputDigest = { bypass: input.bypass }
88
81
  if (input.variable) result.variable = input.variable
@@ -95,36 +88,19 @@ function digestInput(input: ContentActionItem['input']): InputDigest | undefined
95
88
  }
96
89
  }
97
90
  if (input.expiration) result.expiration = input.expiration
98
- const conditions = digestConditions(input.conditions as ActionDigest['conditions'])
91
+ const conditions = digestConditions(input.conditions)
99
92
  if (conditions) result.conditions = conditions
100
93
  return result
101
94
  }
102
95
 
103
- /**
104
- * Typing indicators are noise in every projection of a flow, so both the digest
105
- * and the minifier drop them. The two signals are checked because `$typeOfContent`
106
- * is absent from a minified flow and can come back empty from a GET, while the
107
- * settings mime type is absent from a hand-written action.
108
- */
109
- export function isChatStateSendMessage(item: ContentActionItem): boolean {
110
- if (item.action?.type !== 'SendMessage') {
111
- return false
112
- }
113
-
114
- return (
115
- item.action.$typeOfContent === 'chat-state' ||
116
- item.action.settings.type === 'application/vnd.lime.chatstate+json'
117
- )
118
- }
119
-
120
- export function resolveStateName(stateId: string, flow: Record<string, State>): string | undefined {
96
+ export function resolveStateName(stateId: string, flow: Record<string, MinifiedState>): string | undefined {
121
97
  const state = flow[stateId]
122
98
  return state?.$title
123
99
  }
124
100
 
125
101
  function digestOutput(
126
- output: { stateId: string; typeOfStateId?: string; conditions?: Array<unknown>; $invalid?: boolean },
127
- flow: Record<string, State>,
102
+ output: { stateId: string; typeOfStateId?: string; conditions?: Array<Condition> },
103
+ flow: Record<string, MinifiedState>,
128
104
  ): OutputDigest {
129
105
  const result: OutputDigest = {
130
106
  stateId: output.stateId,
@@ -132,48 +108,39 @@ function digestOutput(
132
108
  const name = resolveStateName(output.stateId, flow)
133
109
  if (name) result.stateName = name
134
110
  if (output.typeOfStateId) result.typeOfStateId = output.typeOfStateId
135
- if ('conditions' in output && Array.isArray(output.conditions) && output.conditions.length > 0) {
136
- result.conditions = digestConditions(output.conditions as ActionDigest['conditions'])
137
- }
111
+ const conditions = digestConditions(output.conditions)
112
+ if (conditions) result.conditions = conditions
138
113
  return result
139
114
  }
140
115
 
141
- export function digestState(stateId: string, state: State, flow: Record<string, State>): StateDigest {
116
+ export function digestState(stateId: string, state: MinifiedState, flow: Record<string, MinifiedState>): StateDigest {
142
117
  const result: StateDigest = {
143
118
  id: stateId,
144
119
  name: state.$title,
145
- defaultOutput: {
146
- stateId: state.$defaultOutput.stateId,
147
- ...(resolveStateName(state.$defaultOutput.stateId, flow)
148
- ? { stateName: resolveStateName(state.$defaultOutput.stateId, flow) }
149
- : {}),
150
- ...(state.$defaultOutput.typeOfStateId ? { typeOfStateId: state.$defaultOutput.typeOfStateId } : {}),
151
- },
120
+ defaultOutput: digestOutput(state.$defaultOutput, flow),
152
121
  }
153
122
 
154
- // Derived from the id so the digest reads the same whether it is handed a
155
- // full flow or a minified one, which no longer carries `root`.
156
- if (state.root || stateId === ROOT_STATE_ID) result.root = true
123
+ if (resolveEntryPointId(flow) === stateId) result.root = true
157
124
 
158
125
  const enteringActions = state.$enteringCustomActions.map(digestAction)
159
126
  if (enteringActions.length > 0) result.enteringActions = enteringActions
160
127
 
161
128
  const contentActions = state.$contentActions
162
- .filter((item: ContentActionItem) => !isChatStateSendMessage(item))
163
- .map((item: ContentActionItem) => {
129
+ .filter((item) => !isChatStateSendMessage(item))
130
+ .map((item) => {
164
131
  const entry: { action?: ActionDigest; input?: InputDigest } = {}
165
132
  if (item.action) entry.action = digestAction(item.action)
166
133
  const input = digestInput(item.input)
167
134
  if (input) entry.input = input
168
135
  return entry
169
136
  })
170
- .filter((entry: { action?: ActionDigest; input?: InputDigest }) => entry.action || entry.input)
137
+ .filter((entry) => entry.action || entry.input)
171
138
  if (contentActions.length > 0) result.contentActions = contentActions
172
139
 
173
140
  const leavingActions = state.$leavingCustomActions.map(digestAction)
174
141
  if (leavingActions.length > 0) result.leavingActions = leavingActions
175
142
 
176
- const outputs = state.$conditionOutputs.map((o: State['$conditionOutputs'][number]) => digestOutput(o, flow))
143
+ const outputs = state.$conditionOutputs.map((o) => digestOutput(o, flow))
177
144
  if (outputs.length > 0) result.outputs = outputs
178
145
 
179
146
  const afterStateChangedActions = (state.$afterStateChangedActions ?? []).map(digestAction)
@@ -0,0 +1,453 @@
1
+ /**
2
+ * Everything the Builder runtime refuses to run, checked against a minified flow.
3
+ *
4
+ * A port of the runtime's own validation — `Models/Flow.cs::Validate`,
5
+ * `Models/Condition.cs::Validate`, `Models/Input.cs::Validate` and the media type
6
+ * each action deserializes. All of them throw at load or mid-turn, and the runtime
7
+ * validates the whole flow on every message, so one of these takes the bot down
8
+ * for every user rather than breaking one branch.
9
+ *
10
+ * The line is deliberate: this file only holds what makes the runtime throw. A
11
+ * flow that is merely suspect — a quick reply nothing matches, a variable nobody
12
+ * writes — parses fine here, because that is a linter's job and a schema can only
13
+ * say "invalid" where a linter can say "warning".
14
+ *
15
+ * It is one pass over the whole flow, wired into `minifiedFlowSchema` alone. Zod
16
+ * skips a `.check()` on a value that already has issues, so a check per action and
17
+ * a check per state would report the innermost problem and hide the rest.
18
+ */
19
+
20
+ import type { Condition } from '../types/flow.ts'
21
+ import { CUSTOM_ACTION_LISTS, inputOf, ROOT_STATE_ID, resolveEntryPointId } from '../types/flow.ts'
22
+ import type { MinifiedAction, MinifiedInput, MinifiedState } from './minifier.ts'
23
+
24
+ /** Every rule this file can report. */
25
+ export type FlowRule =
26
+ | 'missing-root-state'
27
+ | 'root-must-wait'
28
+ | 'dangling-output'
29
+ | 'input-less-loop'
30
+ | 'transition-loop'
31
+ | 'shadowed-output'
32
+ | 'invalid-condition'
33
+ | 'invalid-input-validation'
34
+ | 'invalid-regex'
35
+ | 'invalid-variable-name'
36
+ | 'unreadable-variable'
37
+ | 'malformed-placeholder'
38
+ | 'missing-media-type'
39
+ | 'content-type-mismatch'
40
+ | 'duplicate-title'
41
+
42
+ /** Reports a failure, tagged with the rule so a caller can group or suppress. */
43
+ export type ReportRule = (rule: FlowRule, path: Array<string | number>, message: string) => void
44
+
45
+ /**
46
+ * The rule a zod issue came from, if it came from one of these checks.
47
+ *
48
+ * The id travels in the issue's `params`, which zod types as `Record<string, any>`,
49
+ * so reading it through here is what keeps `FlowRule` meaning anything on the way
50
+ * out — a caller that reaches into `params` itself gets `any` back.
51
+ */
52
+ export function flowRuleOf(issue: { code: string; params?: Record<string, unknown> }): FlowRule | undefined {
53
+ const rule = issue.code === 'custom' ? issue.params?.rule : undefined
54
+
55
+ return typeof rule === 'string' ? (rule as FlowRule) : undefined
56
+ }
57
+
58
+ type Path = Array<string | number>
59
+
60
+ const UNARY_COMPARISONS = new Set(['exists', 'notExists'])
61
+
62
+ /** `Models/Input.cs`: the runtime rejects an input variable outside this set. */
63
+ const INPUT_VARIABLE_PATTERN = /^[a-zA-Z0-9.]+$/
64
+
65
+ /** `Utils/VariableReplacer.cs`: the only shape the runtime substitutes. */
66
+ const VARIABLE_ALPHABET = '[a-zA-Z0-9.@_-]'
67
+ export const VARIABLE_NAME = new RegExp(`^${VARIABLE_ALPHABET}+$`)
68
+
69
+ /** Anything brace-wrapped, so the shapes the replacer skips can be reported too. */
70
+ const PLACEHOLDER_PATTERN = /{{([^{}]*)}}/g
71
+
72
+ /** `ContextBase.cs`: what `VariableName.Parse` accepts before the `@`. */
73
+ const READABLE_NAME_PATTERN = /^[\w\d]+(\.[\w\d.]+)?$/
74
+
75
+ const MEDIA_TYPE_PATTERN = /^[a-z]+\/[\w.+-]+$/i
76
+
77
+ /** `Variables/VariableSource.cs`: a name whose first segment is one of these reads a provider. */
78
+ export const PROVIDER_NAMESPACES = new Set([
79
+ 'contact',
80
+ 'calendar',
81
+ 'random',
82
+ 'bucket',
83
+ 'config',
84
+ 'input',
85
+ 'state',
86
+ 'tunnel',
87
+ 'application',
88
+ 'ticket',
89
+ 'resource',
90
+ 'aianswers',
91
+ 'secret',
92
+ 'blipfunction',
93
+ 'aiagent',
94
+ ])
95
+
96
+ /** `Models/Flow.cs`: an output names a state, or a `{{variable}}` resolved at runtime. */
97
+ export const isVariableOutput = (stateId: string) => stateId.startsWith('{{') && stateId.endsWith('}}')
98
+
99
+ /**
100
+ * `Models/Flow.cs::Validate`, walked in one pass so every problem in the flow is
101
+ * reported at once.
102
+ */
103
+ export function checkFlow(flow: Record<string, MinifiedState>, report: ReportRule): void {
104
+ checkEntryPoint(flow, report)
105
+
106
+ const titles = new Map<string, string>()
107
+
108
+ for (const [stateId, state] of Object.entries(flow)) {
109
+ // The runtime keys states by id and does not care, but the builder
110
+ // addresses them by title, so a duplicate hides one on the canvas.
111
+ const title = state.$title.toLowerCase()
112
+ const duplicate = titles.get(title)
113
+
114
+ if (duplicate) {
115
+ report(
116
+ 'duplicate-title',
117
+ [stateId, '$title'],
118
+ `Duplicate state title '${state.$title}', already used by '${duplicate}'`,
119
+ )
120
+ } else {
121
+ titles.set(title, stateId)
122
+ }
123
+
124
+ for (const [output, path] of outputsOf(state)) {
125
+ if (!isVariableOutput(output.stateId) && !(output.stateId in flow)) {
126
+ report(
127
+ 'dangling-output',
128
+ [stateId, ...path, 'stateId'],
129
+ `'${output.stateId}' is not a state in this flow, and is not a {{variable}}`,
130
+ )
131
+ }
132
+ }
133
+
134
+ checkLoops(stateId, state, flow, report)
135
+ checkState(stateId, state, report)
136
+ }
137
+ }
138
+
139
+ /** `Models/Flow.cs`: one entry point, and it has to wait for an input. */
140
+ function checkEntryPoint(flow: Record<string, MinifiedState>, report: ReportRule): void {
141
+ const declared = Object.values(flow).filter((state) => state.root).length
142
+
143
+ if (declared > 1) {
144
+ report(
145
+ 'missing-root-state',
146
+ [],
147
+ `${declared} states are marked "root": true, and a flow can only have one entry point`,
148
+ )
149
+ return
150
+ }
151
+
152
+ const stateId = resolveEntryPointId(flow)
153
+
154
+ if (!stateId) {
155
+ report(
156
+ 'missing-root-state',
157
+ [],
158
+ `No state is marked "root": true, and there is no '${ROOT_STATE_ID}' state to fall back to`,
159
+ )
160
+ return
161
+ }
162
+
163
+ const input = inputOf(flow[stateId])
164
+
165
+ if (!input || input.bypass) {
166
+ report(
167
+ 'root-must-wait',
168
+ [stateId],
169
+ 'The entry point has to wait for an input: give it an input that is not bypassed',
170
+ )
171
+ }
172
+
173
+ if (input?.conditions?.length) {
174
+ report('root-must-wait', [stateId], "The entry point's input must not have conditions")
175
+ }
176
+ }
177
+
178
+ /** What one state decides on its own, plus its inputs and actions. */
179
+ function checkState(stateId: string, state: MinifiedState, report: ReportRule): void {
180
+ for (const [index, output] of state.$conditionOutputs.entries()) {
181
+ // `ConditionsExtensions.cs` returns true for an empty list, so an output
182
+ // with no conditions always matches and hides every output after it.
183
+ if (output.conditions.length === 0 && index < state.$conditionOutputs.length - 1) {
184
+ report(
185
+ 'shadowed-output',
186
+ [stateId, '$conditionOutputs', index],
187
+ `This output has no conditions, so it always matches and the ${
188
+ state.$conditionOutputs.length - index - 1
189
+ } output(s) after it can never run`,
190
+ )
191
+ }
192
+
193
+ for (const [conditionIndex, condition] of output.conditions.entries()) {
194
+ checkCondition(condition, [stateId, '$conditionOutputs', index, 'conditions', conditionIndex], report)
195
+ }
196
+ }
197
+
198
+ for (const [index, item] of state.$contentActions.entries()) {
199
+ if (item.input) {
200
+ checkInput(item.input, [stateId, '$contentActions', index, 'input'], report)
201
+ }
202
+ if (item.action) {
203
+ checkAction(item.action, [stateId, '$contentActions', index, 'action'], report)
204
+ }
205
+ }
206
+
207
+ for (const list of CUSTOM_ACTION_LISTS) {
208
+ for (const [index, action] of (state[list] ?? []).entries()) {
209
+ checkAction(action, [stateId, list, index], report)
210
+ }
211
+ }
212
+
213
+ checkVariableReads(stateId, state, report)
214
+ }
215
+
216
+ /** `Models/Condition.cs::Validate`, plus the regex the comparison would compile. */
217
+ function checkCondition(condition: Condition, path: Path, report: ReportRule): void {
218
+ if (condition.source === 'context' && !condition.variable) {
219
+ report('invalid-condition', path, 'A condition on the context has to name a variable')
220
+ }
221
+
222
+ const takesValues = !UNARY_COMPARISONS.has(condition.comparison)
223
+
224
+ if (takesValues !== condition.values.length > 0) {
225
+ report(
226
+ 'invalid-condition',
227
+ [...path, 'values'],
228
+ takesValues
229
+ ? `'${condition.comparison}' has no values to compare against`
230
+ : `'${condition.comparison}' is rejected by the runtime when values are provided`,
231
+ )
232
+ }
233
+
234
+ if (condition.comparison === 'matches') {
235
+ for (const [index, value] of condition.values.entries()) {
236
+ checkRegex(value, [...path, 'values', index], report)
237
+ }
238
+ }
239
+ }
240
+
241
+ /** `Models/Input.cs::Validate`. */
242
+ function checkInput(input: MinifiedInput, path: Path, report: ReportRule): void {
243
+ if (input.variable && !INPUT_VARIABLE_PATTERN.test(input.variable)) {
244
+ report(
245
+ 'invalid-variable-name',
246
+ [...path, 'variable'],
247
+ `'${input.variable}' is rejected by the runtime: letters, numbers and dots only`,
248
+ )
249
+ }
250
+
251
+ if (input.validation) {
252
+ const { rule, regex, type, error } = input.validation
253
+
254
+ if (rule === 'regex' && !regex) {
255
+ report('invalid-input-validation', [...path, 'validation', 'regex'], "The 'regex' rule needs a regex")
256
+ }
257
+
258
+ if (rule === 'type' && !type) {
259
+ report('invalid-input-validation', [...path, 'validation', 'type'], "The 'type' rule needs a media type")
260
+ }
261
+
262
+ // Required for every rule, not just regex.
263
+ if (!error.trim()) {
264
+ report(
265
+ 'invalid-input-validation',
266
+ [...path, 'validation', 'error'],
267
+ 'The validation needs the message to send when the answer does not pass',
268
+ )
269
+ }
270
+
271
+ if (regex) {
272
+ checkRegex(regex, [...path, 'validation', 'regex'], report)
273
+ }
274
+ }
275
+
276
+ for (const [index, condition] of (input.conditions ?? []).entries()) {
277
+ checkCondition(condition, [...path, 'conditions', index], report)
278
+ }
279
+ }
280
+
281
+ /** What each action needs before the runtime will run it. */
282
+ function checkAction(action: MinifiedAction, path: Path, report: ReportRule): void {
283
+ for (const [index, condition] of (action.conditions ?? []).entries()) {
284
+ checkCondition(condition, [...path, 'conditions', index], report)
285
+ }
286
+
287
+ if (action.type !== 'SendMessage' && action.type !== 'SendRawMessage') {
288
+ return
289
+ }
290
+
291
+ const { type } = action.settings
292
+
293
+ if (!type) {
294
+ report('missing-media-type', [...path, 'settings', 'type'], `A ${action.type} action requires a settings.type`)
295
+ return
296
+ }
297
+
298
+ if (action.type === 'SendRawMessage') {
299
+ if (!MEDIA_TYPE_PATTERN.test(type)) {
300
+ report('missing-media-type', [...path, 'settings', 'type'], `'${type}' is not a valid media type`)
301
+ }
302
+ return
303
+ }
304
+
305
+ // `SendMessageAction.cs` deserializes a `+json` content into a dictionary,
306
+ // which throws on a string. The plain branch stringifies whatever it gets, so
307
+ // only the document types are worth checking.
308
+ if (type !== 'text/plain' && typeof action.settings.content !== 'object') {
309
+ report(
310
+ 'content-type-mismatch',
311
+ [...path, 'settings', 'content'],
312
+ `A '${type}' message needs an object as its content`,
313
+ )
314
+ }
315
+ }
316
+
317
+ /**
318
+ * A state that can be reached again without the user ever answering.
319
+ *
320
+ * `Flow.cs` walks this transitively and refuses to load the flow when the state
321
+ * has no input element at all; a state whose only way out is itself burns the ten
322
+ * transitions an input is allowed and throws mid-turn. A bypassed state that has
323
+ * another way out is left alone: paging through a shrinking variable is a real
324
+ * pattern, and it terminates.
325
+ */
326
+ function checkLoops(
327
+ stateId: string,
328
+ state: MinifiedState,
329
+ flow: Record<string, MinifiedState>,
330
+ report: ReportRule,
331
+ ): void {
332
+ const input = inputOf(state)
333
+
334
+ if (input && !input.bypass) {
335
+ return
336
+ }
337
+
338
+ if (outputsOf(state).every(([output]) => output.stateId === stateId)) {
339
+ report(
340
+ 'transition-loop',
341
+ [stateId],
342
+ 'Every output leads back to this state and it does not wait for an input, so the runtime loops until it hits the transition limit',
343
+ )
344
+ return
345
+ }
346
+
347
+ if (input) {
348
+ return
349
+ }
350
+
351
+ const seen = new Set([stateId])
352
+ const pending = [stateId]
353
+
354
+ while (pending.length > 0) {
355
+ for (const [output] of outputsOf(flow[pending.pop()!])) {
356
+ if (output.stateId === stateId) {
357
+ report(
358
+ 'input-less-loop',
359
+ [stateId],
360
+ 'This state has no input and can be reached again without the user ever answering, which the runtime refuses to load',
361
+ )
362
+ return
363
+ }
364
+
365
+ const next = flow[output.stateId]
366
+
367
+ if (!next || seen.has(output.stateId)) {
368
+ continue
369
+ }
370
+
371
+ // A state that waits for an answer breaks the chain.
372
+ const nextInput = inputOf(next)
373
+
374
+ if (!nextInput || nextInput.bypass) {
375
+ seen.add(output.stateId)
376
+ pending.push(output.stateId)
377
+ }
378
+ }
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Every `{{reference}}` a state carries, deduped, with script sources masked out:
384
+ * a script's braces are a doc comment or a nested object literal, not a read.
385
+ */
386
+ export function placeholderReadsOf(state: MinifiedState): Array<string> {
387
+ const readable = JSON.stringify(state, (_key, value) =>
388
+ value?.type === 'ExecuteScript' || value?.type === 'ExecuteScriptV2'
389
+ ? { ...value, settings: { ...value.settings, source: '' } }
390
+ : value,
391
+ )
392
+
393
+ return [...new Set([...readable.matchAll(PLACEHOLDER_PATTERN)].map((match) => match[1]))]
394
+ }
395
+
396
+ /**
397
+ * A `{{...}}` the replacer will not substitute reaches the user with its braces
398
+ * showing, and one it substitutes but `VariableName.Parse` throws on aborts the
399
+ * turn instead of resolving to an empty string.
400
+ */
401
+ function checkVariableReads(stateId: string, state: MinifiedState, report: ReportRule): void {
402
+ for (const reference of placeholderReadsOf(state)) {
403
+ if (!VARIABLE_NAME.test(reference)) {
404
+ report(
405
+ 'malformed-placeholder',
406
+ [stateId],
407
+ `'{{${reference}}}' is not a name the runtime substitutes${
408
+ reference !== reference.trim() ? ' (spaces are not allowed inside the braces)' : ''
409
+ }, so it is sent as written`,
410
+ )
411
+ continue
412
+ }
413
+
414
+ const name = reference.split('@')[0]
415
+ const namespace = name.split('.')[0]
416
+
417
+ if (PROVIDER_NAMESPACES.has(namespace.toLowerCase())) {
418
+ continue
419
+ }
420
+
421
+ if (!READABLE_NAME_PATTERN.test(name)) {
422
+ report(
423
+ 'unreadable-variable',
424
+ [stateId],
425
+ `'{{${name}}}' cannot be parsed by the runtime — a hyphen is the usual cause — and reading it aborts the turn`,
426
+ )
427
+ } else if (name.includes('.')) {
428
+ report(
429
+ 'unreadable-variable',
430
+ [stateId],
431
+ `'{{${name}}}' looks like a provider read, but '${namespace}' is not one, so reading it aborts the turn. A context variable cannot contain a dot`,
432
+ )
433
+ }
434
+ }
435
+ }
436
+
437
+ function checkRegex(pattern: string, path: Path, report: ReportRule): void {
438
+ try {
439
+ new RegExp(pattern)
440
+ } catch {
441
+ report('invalid-regex', path, `'${pattern}' is not a valid regular expression`)
442
+ }
443
+ }
444
+
445
+ function outputsOf(state: MinifiedState): Array<[{ stateId: string }, Path]> {
446
+ return [
447
+ [state.$defaultOutput, ['$defaultOutput']],
448
+ ...state.$conditionOutputs.map((output, index): [{ stateId: string }, Path] => [
449
+ output,
450
+ ['$conditionOutputs', index],
451
+ ]),
452
+ ]
453
+ }