@astrale-os/sdk 0.6.0-beta.37 → 0.6.0-beta.39

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 (28) hide show
  1. package/dist/application/runtime/workflows.js +0 -3
  2. package/dist/application/workflow/authoring.d.ts +1 -1
  3. package/dist/application/workflow/define.js +4 -15
  4. package/dist/application/workflow/durable.d.ts +43 -4
  5. package/dist/application/workflow/index.d.ts +1 -1
  6. package/dist/application/workflow/workflow.d.ts +0 -8
  7. package/dist/deployment/address/configuration.d.ts +1 -0
  8. package/dist/deployment/address/configuration.js +2 -1
  9. package/dist/execution/index.d.ts +1 -0
  10. package/dist/execution/index.js +1 -0
  11. package/dist/execution/workflows/engine.d.ts +19 -1
  12. package/dist/execution/workflows/failure.d.ts +40 -5
  13. package/dist/execution/workflows/failure.js +123 -6
  14. package/dist/execution/workflows/index.d.ts +2 -2
  15. package/dist/execution/workflows/index.js +1 -1
  16. package/dist/execution/workflows/options.d.ts +36 -9
  17. package/dist/execution/workflows/options.js +82 -19
  18. package/dist/execution/workflows/receiver.js +2 -0
  19. package/dist/execution/workflows/run.js +69 -29
  20. package/dist/testing/durable/engine.d.ts +16 -6
  21. package/dist/testing/durable/engine.js +67 -15
  22. package/dist/tooling/linter/implementations/source/dependencies.js +1 -73
  23. package/dist/tooling/linter/implementations/source/shared.d.ts +7 -0
  24. package/dist/tooling/linter/implementations/source/shared.js +92 -13
  25. package/dist/tooling/linter/implementations/source/workflows.js +426 -3
  26. package/dist/tooling/linter/policy/generated.js +2 -2
  27. package/dist/tooling/linter/requirements/registry.js +3 -1
  28. package/package.json +6 -6
@@ -215,22 +215,101 @@ export function hasForbiddenGlobalCall(file) {
215
215
  visit(file.source, (node) => {
216
216
  if (!ts.isCallExpression(node))
217
217
  return;
218
- const expression = unwrap(node.expression);
219
- if (ts.isIdentifier(expression)) {
220
- const name = expression.text;
221
- if (forbidden.has(name) &&
222
- !importedSymbol(file, expression) &&
223
- !isLocallyBound(expression, name)) {
224
- result.push({ node, name });
218
+ const name = globalCallName(file, node);
219
+ if (name !== undefined && forbidden.has(name))
220
+ result.push({ node, name });
221
+ });
222
+ return result;
223
+ }
224
+ /** The global a call reaches, as `name(…)` with no import or local binding, or `globalThis.name(…)`. */
225
+ export function globalCallName(file, node) {
226
+ const expression = unwrap(node.expression);
227
+ if (ts.isIdentifier(expression)) {
228
+ const name = expression.text;
229
+ return importedSymbol(file, expression) || isLocallyBound(expression, name) ? undefined : name;
230
+ }
231
+ const chain = propertyChain(expression);
232
+ if (chain?.length !== 2 || chain[0] !== 'globalThis')
233
+ return undefined;
234
+ let root = expression;
235
+ while (ts.isPropertyAccessExpression(root))
236
+ root = unwrap(root.expression);
237
+ return ts.isIdentifier(root) && isLocallyBound(root, root.text) ? undefined : chain[1];
238
+ }
239
+ /**
240
+ * Resolve the path of `input` from the first parameter of `run`, or of a durable Step callback in
241
+ * it, through one immutable local alias, without following arbitrary helpers.
242
+ */
243
+ export function contextPath(input, run, aliases = 1) {
244
+ const expression = unwrap(input);
245
+ if (ts.isPropertyAccessExpression(expression)) {
246
+ const base = contextPath(expression.expression, run, aliases);
247
+ return base && [...base, expression.name.text];
248
+ }
249
+ if (ts.isElementAccessExpression(expression)) {
250
+ const base = contextPath(expression.expression, run, aliases);
251
+ return base && [...base, '[computed]'];
252
+ }
253
+ if (!ts.isIdentifier(expression))
254
+ return undefined;
255
+ let current = expression.parent;
256
+ while (current) {
257
+ if (ts.isFunctionLike(current)) {
258
+ for (const parameter of current.parameters) {
259
+ const path = bindingPath(parameter.name, expression.text);
260
+ if (path)
261
+ return parameter === current.parameters[0] &&
262
+ (current === run || stepCallback(current, run))
263
+ ? path
264
+ : undefined;
225
265
  }
226
- return;
227
266
  }
228
- const chain = propertyChain(expression);
229
- if (chain?.length === 2 && chain[0] === 'globalThis' && forbidden.has(chain[1])) {
230
- result.push({ node, name: chain[1] });
267
+ if (ts.isBlock(current) || ts.isSourceFile(current)) {
268
+ for (const statement of current.statements) {
269
+ if (ts.isFunctionDeclaration(statement) && statement.name?.text === expression.text)
270
+ return undefined;
271
+ if (!ts.isVariableStatement(statement))
272
+ continue;
273
+ for (const declaration of statement.declarationList.declarations) {
274
+ const path = bindingPath(declaration.name, expression.text);
275
+ if (!path)
276
+ continue;
277
+ if (aliases === 0 ||
278
+ !declaration.initializer ||
279
+ declaration.end > expression.pos ||
280
+ !(statement.declarationList.flags & ts.NodeFlags.Const))
281
+ return undefined;
282
+ const base = contextPath(declaration.initializer, run, aliases - 1);
283
+ return base && [...base, ...path];
284
+ }
285
+ }
231
286
  }
232
- });
233
- return result;
287
+ current = current.parent;
288
+ }
289
+ return undefined;
290
+ }
291
+ /** A durable Step callback receives the handler's capabilities under the same names. */
292
+ function stepCallback(callback, run) {
293
+ const call = callback.parent;
294
+ if (!ts.isCallExpression(call) || call.arguments[1] !== callback)
295
+ return false;
296
+ const callee = contextPath(call.expression, run, 0);
297
+ return callee?.length === 2 && callee[0] === 'step' && callee[1] === 'run';
298
+ }
299
+ function bindingPath(name, identifier) {
300
+ if (ts.isIdentifier(name))
301
+ return name.text === identifier ? [] : undefined;
302
+ if (!ts.isObjectBindingPattern(name))
303
+ return undefined;
304
+ for (const element of name.elements) {
305
+ if (element.dotDotDotToken)
306
+ continue;
307
+ const nested = bindingPath(element.name, identifier);
308
+ const property = element.propertyName ?? (ts.isIdentifier(element.name) ? element.name : undefined);
309
+ if (nested && property && (ts.isIdentifier(property) || ts.isStringLiteral(property)))
310
+ return [property.text, ...nested];
311
+ }
312
+ return undefined;
234
313
  }
235
314
  export function isLocallyBoundIdentifier(node) {
236
315
  return isLocallyBound(node, node.text);
@@ -1,8 +1,35 @@
1
1
  import ts from 'typescript';
2
- import { propertyChain, unwrap, visit, visitOwnBody, } from '../../adapters/typescript/index.js';
3
- import { ambiguity, calls, production, staticId, violation } from './shared.js';
2
+ import { expressionType, propertyChain, unwrap, visit, visitOwnBody, } from '../../adapters/typescript/index.js';
3
+ import { ambiguity, calls, contextPath, globalCallName, production, propertyExpression, staticId, violation, } from './shared.js';
4
4
  // `query` and `mutate` are the durable Step sugar over `run`; each is one Step too.
5
5
  const STEP_METHODS = new Set(['run', 'query', 'mutate']);
6
+ // Members only a durable Workflow context has: using one proves the Workflow durable.
7
+ const DURABLE_CONTEXT_MEMBERS = new Set([
8
+ 'awaitSignal',
9
+ 'awaitExecution',
10
+ 'cancelled',
11
+ 'exhausted',
12
+ 'retryAfter',
13
+ 'sleep',
14
+ ]);
15
+ const AUTHORITIES = new Set(['caller', 'self']);
16
+ // Raw Kernel graph reads a Step's `kernel` offers beside `mutate`.
17
+ const KERNEL_READS = new Set(['query', 'get', 'getOrThrow', 'neighbors']);
18
+ // Array methods that run their callback once per element.
19
+ const ITERATING_METHODS = new Set([
20
+ 'every',
21
+ 'filter',
22
+ 'find',
23
+ 'findIndex',
24
+ 'findLast',
25
+ 'findLastIndex',
26
+ 'flatMap',
27
+ 'forEach',
28
+ 'map',
29
+ 'reduce',
30
+ 'reduceRight',
31
+ 'some',
32
+ ]);
6
33
  export const workflowRules = [
7
34
  {
8
35
  id: 'FNC-INT-TYPES',
@@ -51,7 +78,7 @@ export const workflowRules = [
51
78
  const method = stepMethod(node, roots);
52
79
  if (!method)
53
80
  return;
54
- const id = staticId(node.arguments[0]);
81
+ const id = stepId(node.arguments[0]);
55
82
  if (!id || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(id)) {
56
83
  evidence.push(violation(file, node.arguments[0] ?? node, `Workflow ${method} step ID is not a non-empty kebab-case literal.`));
57
84
  }
@@ -110,7 +137,403 @@ export const workflowRules = [
110
137
  return evidence;
111
138
  },
112
139
  },
140
+ {
141
+ id: 'FNC-STEP-READS',
142
+ ruleRevision: '16b3a7c85214d5c0c6cfaede5099c16223da1c3fd98dec9781910634bab89343',
143
+ evaluate(project) {
144
+ const evidence = [];
145
+ for (const step of durableSteps(project, evidence)) {
146
+ const operations = stepOperations(project, step);
147
+ const effects = operations.filter(({ kind }) => kind === 'effect');
148
+ const reads = operations.filter((operation) => operation.kind === 'observation' &&
149
+ effects.some((effect) => precedes(operation, effect, step.callback)));
150
+ if (reads.length === 0)
151
+ continue;
152
+ evidence.push(violation(step.file, step.call, `Durable step ${step.label} reads (${names(reads)}) before it writes (${names(effects)}); a retry after its write would read that write. Read in an earlier step.`));
153
+ }
154
+ return evidence;
155
+ },
156
+ },
157
+ {
158
+ id: 'FNC-EFFECT-STEP',
159
+ ruleRevision: 'edadc22d3dbe2d35a4e8c0c341245808cb2d9f4fb1accf953a05f6162956bea1',
160
+ evaluate(project) {
161
+ const evidence = [];
162
+ for (const step of durableSteps(project, evidence)) {
163
+ const effects = stepOperations(project, step).filter(({ kind }) => kind === 'effect');
164
+ // An effect runs again when it sits in a loop or two of its traces can run in one run.
165
+ const again = (effect) => effect.traces.some((trace) => trace.some((node) => repeated(node, step.callback))) ||
166
+ together(effect.traces, effect.traces, step.callback);
167
+ const involved = effects.filter((effect) => again(effect) ||
168
+ effects.some((other) => other !== effect && together(effect.traces, other.traces, step.callback)));
169
+ if (involved.length === 0)
170
+ continue;
171
+ const listed = involved
172
+ .map((effect) => (again(effect) ? `${effect.name} repeatedly` : effect.name))
173
+ .join(', ');
174
+ evidence.push(violation(step.file, step.call, `Durable step ${step.label} performs several effects (${listed}); if one fails after another committed, the Workflow cannot tell. Give each effect its own step.`));
175
+ }
176
+ return evidence;
177
+ },
178
+ },
179
+ {
180
+ id: 'FNC-STEP-RETRY',
181
+ ruleRevision: 'fefbd4cb783e08b0dfe49788ff036ecff61da9190fd1b64152f92ef3a254a195',
182
+ evaluate(project) {
183
+ const evidence = [];
184
+ for (const step of durableSteps(project, evidence)) {
185
+ for (const operation of stepOperations(project, step)) {
186
+ if (operation.kind !== 'unkeyed')
187
+ continue;
188
+ evidence.push(violation(step.file, operation.node, `Durable step ${step.label} calls ${operation.name}, which runs again when the step is retried; make it idempotent, declare the callee durable, or move it out of the durable Workflow.`));
189
+ }
190
+ }
191
+ return evidence;
192
+ },
193
+ },
113
194
  ];
195
+ /**
196
+ * The `step.run` callbacks of every Workflow its source proves durable: it uses a member only the
197
+ * durable context has, `step.query` or `step.mutate`, or a Step callback that receives
198
+ * capabilities. An inline Workflow never retries a step and is not inspected.
199
+ */
200
+ function durableSteps(project, evidence) {
201
+ const steps = [];
202
+ for (const file of production(project, 'functions')) {
203
+ const helpers = localFunctions(file.source);
204
+ for (const definition of workflowDefinitions(file)) {
205
+ if (definition.origin === 'ambiguous') {
206
+ evidence.push(ambiguity(file, definition.call, 'Workflow definition resolves through a local facade whose ultimate public constructor origin is unknown.'));
207
+ continue;
208
+ }
209
+ const { run } = definition;
210
+ if (!run)
211
+ continue;
212
+ const roots = stepRoots(run);
213
+ const runs = [];
214
+ let durable = usesDurableContext(run);
215
+ visitOwnBody(run, (node) => {
216
+ if (!ts.isCallExpression(node))
217
+ return;
218
+ const method = stepMethod(node, roots);
219
+ if (!method)
220
+ return;
221
+ if (method === 'query' || method === 'mutate' || declaredStep(node.arguments[0])) {
222
+ durable = true;
223
+ }
224
+ if (method !== 'run')
225
+ return;
226
+ const callback = node.arguments[1] && stepRunCallback(node.arguments[1], helpers);
227
+ if (!callback)
228
+ return;
229
+ if (callback.parameters.length > 0)
230
+ durable = true;
231
+ const id = stepId(node.arguments[0]);
232
+ runs.push({ file, call: node, label: id ?? 'with a dynamic id', callback });
233
+ });
234
+ if (durable)
235
+ steps.push(...runs);
236
+ }
237
+ }
238
+ return steps;
239
+ }
240
+ /** Only a durable Step takes a declaration, `{ id, retry, timeoutMs }`, in place of its id. */
241
+ function declaredStep(argument) {
242
+ return argument !== undefined && ts.isObjectLiteralExpression(unwrap(argument));
243
+ }
244
+ /** A Step's literal id: its first argument, or the `id` of the declaration written there. */
245
+ function stepId(argument) {
246
+ if (argument === undefined)
247
+ return undefined;
248
+ const value = unwrap(argument);
249
+ return ts.isObjectLiteralExpression(value)
250
+ ? staticId(propertyExpression(value, 'id'))
251
+ : staticId(value);
252
+ }
253
+ /**
254
+ * A `step.run` callback written inline or named by one local function. Any other is not inspected;
255
+ * FNC-NO-NEST reports it as ambiguous.
256
+ */
257
+ function stepRunCallback(argument, helpers) {
258
+ const value = unwrap(argument);
259
+ if (ts.isArrowFunction(value) || ts.isFunctionExpression(value))
260
+ return value;
261
+ if (!ts.isIdentifier(value))
262
+ return undefined;
263
+ const candidates = helpers.get(value.text) ?? [];
264
+ return candidates.length === 1 ? candidates[0] : undefined;
265
+ }
266
+ function usesDurableContext(run) {
267
+ const parameter = run.parameters[0]?.name;
268
+ if (parameter && ts.isObjectBindingPattern(parameter)) {
269
+ return parameter.elements.some((element) => {
270
+ const name = element.propertyName ?? element.name;
271
+ return ts.isIdentifier(name) && DURABLE_CONTEXT_MEMBERS.has(name.text);
272
+ });
273
+ }
274
+ if (!parameter || !ts.isIdentifier(parameter) || !run.body)
275
+ return false;
276
+ let found = false;
277
+ visit(run.body, (node) => {
278
+ if (ts.isPropertyAccessExpression(node) &&
279
+ DURABLE_CONTEXT_MEMBERS.has(node.name.text) &&
280
+ contextPath(node, run)?.length === 1)
281
+ found = true;
282
+ });
283
+ return found;
284
+ }
285
+ /**
286
+ * Operations of one step whose kind is provable, with the traces that run them. An operation of
287
+ * unprovable kind, or in a local helper the step never mentions, is omitted.
288
+ */
289
+ function stepOperations(project, step) {
290
+ const operations = [];
291
+ const body = step.callback.body;
292
+ if (!body)
293
+ return operations;
294
+ visit(body, (node) => {
295
+ if (!ts.isCallExpression(node))
296
+ return;
297
+ const operation = callOperation(project, step, node);
298
+ if (!operation)
299
+ return;
300
+ const traces = runTraces(node, step.callback);
301
+ if (traces.length > 0)
302
+ operations.push({ ...operation, traces });
303
+ });
304
+ return operations;
305
+ }
306
+ function callOperation(project, step, node) {
307
+ if (globalCallName(step.file, node) === 'fetch')
308
+ return { kind: 'unkeyed', node, name: 'fetch' };
309
+ const path = contextPath(node.expression, step.callback);
310
+ return path && capabilityOperation(project, step.file, node, path);
311
+ }
312
+ /**
313
+ * The traces through which the step callback reaches `node`: the node itself, or, inside a local
314
+ * helper bound to a name, each mention of that name followed by the node.
315
+ */
316
+ function runTraces(node, callback, active = new Set()) {
317
+ for (let current = node.parent; current && current !== callback; current = current.parent) {
318
+ const name = ts.isFunctionLike(current) ? helperName(current) : undefined;
319
+ if (!name)
320
+ continue;
321
+ if (active.has(current))
322
+ return [];
323
+ active.add(current);
324
+ const traces = mentions(name, callback)
325
+ .filter(invokes)
326
+ .flatMap((mention) => runTraces(mention, callback, active).map((trace) => [...trace, node]));
327
+ active.delete(current);
328
+ return traces;
329
+ }
330
+ return [[node]];
331
+ }
332
+ function helperName(fn) {
333
+ const name = ts.isFunctionDeclaration(fn)
334
+ ? fn.name
335
+ : (ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) &&
336
+ ts.isVariableDeclaration(fn.parent) &&
337
+ fn.parent.initializer === fn
338
+ ? fn.parent.name
339
+ : undefined;
340
+ return name && ts.isIdentifier(name) ? name : undefined;
341
+ }
342
+ /**
343
+ * Whether a mention of a helper runs it: the helper is called, or handed to an iterating Array
344
+ * method as its callback. A helper only referenced, returned or stored is not known to run.
345
+ */
346
+ function invokes(mention) {
347
+ const parent = mention.parent;
348
+ if (!ts.isCallExpression(parent))
349
+ return false;
350
+ return parent.expression === mention || iteratingCallback(mention);
351
+ }
352
+ /** Whether `node` is the callback, the first argument, of an iterating Array method call. */
353
+ function iteratingCallback(node) {
354
+ const parent = node.parent;
355
+ return (parent !== undefined &&
356
+ ts.isCallExpression(parent) &&
357
+ parent.arguments[0] === node &&
358
+ ITERATING_METHODS.has(propertyChain(parent.expression)?.at(-1) ?? ''));
359
+ }
360
+ /** The identifiers of `scope` other than `name` itself that refer to its text, not as a member. */
361
+ function mentions(name, scope) {
362
+ const found = [];
363
+ visit(scope, (node) => {
364
+ if (node === name || !ts.isIdentifier(node) || node.text !== name.text)
365
+ return;
366
+ const parent = node.parent;
367
+ if ((ts.isPropertyAccessExpression(parent) || ts.isPropertyAssignment(parent)) &&
368
+ parent.name === node)
369
+ return;
370
+ found.push(node);
371
+ });
372
+ return found;
373
+ }
374
+ function capabilityOperation(project, file, node, path) {
375
+ const name = path.join('.');
376
+ const method = path.at(-1);
377
+ const selected = (parts) => parts.length === 0 || (parts.length === 1 && AUTHORITIES.has(parts[0]));
378
+ if (path.length === 1 && (method === 'query' || method === 'mutate')) {
379
+ return { kind: method === 'query' ? 'observation' : 'effect', node, name };
380
+ }
381
+ if (path[0] === 'graph' && selected(path.slice(1, -1))) {
382
+ if (method === 'query')
383
+ return { kind: 'observation', node, name };
384
+ if (method === 'mutate')
385
+ return { kind: 'effect', node, name };
386
+ return undefined;
387
+ }
388
+ if (path[0] === 'kernel' && selected(path.slice(1, -1))) {
389
+ if (KERNEL_READS.has(method))
390
+ return { kind: 'observation', node, name };
391
+ if (method === 'mutate')
392
+ return { kind: 'effect', node, name };
393
+ if (method !== 'invoke')
394
+ return undefined;
395
+ const target = node.arguments[0] && unwrap(node.arguments[0]);
396
+ if (target &&
397
+ (ts.isStringLiteralLike(target) ||
398
+ ts.isTemplateExpression(target) ||
399
+ stringTyped(project, file, target))) {
400
+ return { kind: 'unkeyed', node, name: `${name} of an untyped Path` };
401
+ }
402
+ return invocation(project, file, node, name);
403
+ }
404
+ if (path[0] === 'dependencies' && path.length === 4 && AUTHORITIES.has(path[2])) {
405
+ return method === 'invoke' ? invocation(project, file, node, name) : undefined;
406
+ }
407
+ return undefined;
408
+ }
409
+ /**
410
+ * A call the types prove durable answers with its Execution and carries the step's key; one they
411
+ * prove ordinary carries none. Without types, or with an unresolved result, the kind is unprovable.
412
+ */
413
+ function invocation(project, file, node, name) {
414
+ const resolved = expressionType(project, file, node);
415
+ if (resolved === undefined)
416
+ return undefined;
417
+ const output = resolved.checker.getAwaitedType(resolved.type) ?? resolved.type;
418
+ const kinds = new Set((output.isUnion() ? output.types : [output]).map(resultKind));
419
+ if (kinds.size !== 1)
420
+ return undefined;
421
+ const [kind] = kinds;
422
+ if (kind === 'durable')
423
+ return { kind: 'effect', node, name };
424
+ if (kind === 'ordinary') {
425
+ return { kind: 'unkeyed', node, name: `${name} of a callable that is not durable` };
426
+ }
427
+ return undefined;
428
+ }
429
+ /** Whether the types prove `expression` a string, which only an untyped Path is. */
430
+ function stringTyped(project, file, expression) {
431
+ const resolved = expressionType(project, file, expression);
432
+ if (resolved === undefined)
433
+ return false;
434
+ const types = resolved.type.isUnion() ? resolved.type.types : [resolved.type];
435
+ return types.every((type) => (type.flags & ts.TypeFlags.StringLike) !== 0);
436
+ }
437
+ function resultKind(type) {
438
+ if (type.flags &
439
+ (ts.TypeFlags.Any |
440
+ ts.TypeFlags.Unknown |
441
+ ts.TypeFlags.Never |
442
+ ts.TypeFlags.TypeParameter |
443
+ ts.TypeFlags.Conditional |
444
+ ts.TypeFlags.IndexedAccess)) {
445
+ return 'unprovable';
446
+ }
447
+ return (type.aliasSymbol ?? type.getSymbol())?.getName() === 'ExecutionRef'
448
+ ? 'durable'
449
+ : 'ordinary';
450
+ }
451
+ /**
452
+ * Whether some run of the step can reach `read` before `effect` completes. Two traces are ordered
453
+ * where they part, in source order: a read that ends first precedes the effect or builds its
454
+ * arguments, unless both sit in the step's own body and take exclusive branches. Inside a helper no
455
+ * branch is exclusive, since each call may take another. A read after the effect sees the same
456
+ * committed write on every attempt.
457
+ */
458
+ function precedes(read, effect, callback) {
459
+ return read.traces.some((first) => effect.traces.some((second) => before(first, second, callback)));
460
+ }
461
+ /** Whether one run of the step can reach two distinct traces, one from each list. */
462
+ function together(traces, others, callback) {
463
+ return traces.some((trace) => others.some((other) => other !== trace && (before(trace, other, callback) || before(other, trace, callback))));
464
+ }
465
+ /** Whether a run can reach the end of `first` before `second` completes, by the order above. */
466
+ function before(first, second, callback) {
467
+ const index = first.findIndex((node, position) => node !== second[position]);
468
+ const left = first[index];
469
+ const right = second[index];
470
+ if (!left || !right)
471
+ return false;
472
+ return left.end < right.end && !exclusive(left, right, callback);
473
+ }
474
+ /**
475
+ * Whether no run of the step reaches `effect` after `read`: both sit directly in the callback, and
476
+ * they take the two branches of one condition outside any loop, or `read` sits in a branch that
477
+ * returns or throws without `effect`.
478
+ */
479
+ function exclusive(read, effect, callback) {
480
+ if (owner(read) !== callback || owner(effect) !== callback)
481
+ return false;
482
+ for (let node = read; node.parent && node !== callback.body; node = node.parent) {
483
+ const parent = node.parent;
484
+ if (ts.isIfStatement(parent) && node !== parent.expression) {
485
+ const other = node === parent.thenStatement ? parent.elseStatement : parent.thenStatement;
486
+ if (other && within(effect, other) && !repeated(parent, callback))
487
+ return true;
488
+ if (exits(node) && !within(effect, node))
489
+ return true;
490
+ }
491
+ if (ts.isConditionalExpression(parent) && node !== parent.condition) {
492
+ const other = node === parent.whenTrue ? parent.whenFalse : parent.whenTrue;
493
+ if (within(effect, other) && !repeated(parent, callback))
494
+ return true;
495
+ }
496
+ }
497
+ return false;
498
+ }
499
+ /**
500
+ * A site the step may run more than once: in a loop, or in or as the callback of an iterating
501
+ * Array method.
502
+ */
503
+ function repeated(node, callback) {
504
+ for (let current = node; current && current !== callback; current = current.parent) {
505
+ if (current !== node && ts.isIterationStatement(current, false))
506
+ return true;
507
+ if (iteratingCallback(current))
508
+ return true;
509
+ }
510
+ return false;
511
+ }
512
+ /** A statement whose every run ends in `return` or `throw`. */
513
+ function exits(statement) {
514
+ if (ts.isReturnStatement(statement) || ts.isThrowStatement(statement))
515
+ return true;
516
+ if (ts.isBlock(statement)) {
517
+ const last = statement.statements.at(-1);
518
+ return last !== undefined && exits(last);
519
+ }
520
+ return (ts.isIfStatement(statement) &&
521
+ statement.elseStatement !== undefined &&
522
+ exits(statement.thenStatement) &&
523
+ exits(statement.elseStatement));
524
+ }
525
+ function owner(node) {
526
+ let current = node.parent;
527
+ while (current && !ts.isFunctionLike(current))
528
+ current = current.parent;
529
+ return current;
530
+ }
531
+ function within(node, container) {
532
+ return node.pos >= container.pos && node.end <= container.end;
533
+ }
534
+ function names(operations) {
535
+ return operations.map(({ name }) => name).join(', ');
536
+ }
114
537
  function workflowDefinitions(file) {
115
538
  return calls(file, 'defineWorkflow').map(({ call: factory, origin }) => {
116
539
  const call = ts.isCallExpression(factory.parent) && factory.parent.expression === factory