@pikku/inspector 0.12.74 → 0.12.81

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 (219) hide show
  1. package/CHANGELOG.md +347 -0
  2. package/dist/add/add-analytics.d.ts +9 -0
  3. package/dist/add/add-analytics.js +178 -0
  4. package/dist/add/add-auth.js +0 -54
  5. package/dist/add/add-feature-flag.d.ts +2 -0
  6. package/dist/add/add-feature-flag.js +162 -0
  7. package/dist/add/add-wire-addon.js +1 -0
  8. package/dist/add/add-wire-remote-addon.js +1 -0
  9. package/dist/index.d.ts +3 -1
  10. package/dist/index.js +3 -1
  11. package/dist/inspector.js +4 -0
  12. package/dist/surface/collect-surface.d.ts +58 -0
  13. package/dist/surface/collect-surface.js +613 -0
  14. package/dist/types.d.ts +20 -11
  15. package/dist/utils/filter-inspector-state.js +23 -13
  16. package/dist/utils/post-process.js +15 -0
  17. package/dist/utils/read-module-specifiers.d.ts +8 -0
  18. package/dist/utils/read-module-specifiers.js +23 -0
  19. package/dist/utils/read-tsconfig-out-dir.d.ts +5 -0
  20. package/dist/utils/read-tsconfig-out-dir.js +12 -0
  21. package/dist/utils/resolve-deploy-target.d.ts +7 -0
  22. package/dist/utils/resolve-deploy-target.js +15 -7
  23. package/dist/utils/serialize-inspector-state.d.ts +5 -0
  24. package/dist/utils/serialize-inspector-state.js +8 -0
  25. package/dist/visit.js +4 -0
  26. package/package.json +16 -6
  27. package/run-tests.sh +21 -6
  28. package/src/add/add-addon-bans.ts +0 -85
  29. package/src/add/add-agent-scorer.test.ts +0 -197
  30. package/src/add/add-agent-scorer.ts +0 -83
  31. package/src/add/add-agent.test.ts +0 -87
  32. package/src/add/add-agent.ts +0 -638
  33. package/src/add/add-approval-description.ts +0 -76
  34. package/src/add/add-auth.test.ts +0 -791
  35. package/src/add/add-auth.ts +0 -424
  36. package/src/add/add-channel.ts +0 -778
  37. package/src/add/add-cli-renderers.test.ts +0 -74
  38. package/src/add/add-cli.ts +0 -1006
  39. package/src/add/add-credential.ts +0 -186
  40. package/src/add/add-feature.test.ts +0 -306
  41. package/src/add/add-feature.ts +0 -253
  42. package/src/add/add-file-extends-core-type.ts +0 -66
  43. package/src/add/add-file-with-config.ts +0 -45
  44. package/src/add/add-file-with-factory.test.ts +0 -134
  45. package/src/add/add-file-with-factory.ts +0 -288
  46. package/src/add/add-functions.test.ts +0 -743
  47. package/src/add/add-functions.ts +0 -1548
  48. package/src/add/add-gateway.ts +0 -143
  49. package/src/add/add-http-route-ref.test.ts +0 -149
  50. package/src/add/add-http-route.ts +0 -530
  51. package/src/add/add-http-routes.ts +0 -471
  52. package/src/add/add-keyed-wiring.ts +0 -180
  53. package/src/add/add-mcp-prompt.ts +0 -157
  54. package/src/add/add-mcp-resource.ts +0 -175
  55. package/src/add/add-middleware.test.ts +0 -196
  56. package/src/add/add-middleware.ts +0 -879
  57. package/src/add/add-permission-auth.test.ts +0 -59
  58. package/src/add/add-permission.ts +0 -311
  59. package/src/add/add-personas.test.ts +0 -406
  60. package/src/add/add-personas.ts +0 -287
  61. package/src/add/add-queue-worker.ts +0 -117
  62. package/src/add/add-rpc-invocations.test.ts +0 -287
  63. package/src/add/add-rpc-invocations.ts +0 -221
  64. package/src/add/add-scenario-step.test.ts +0 -509
  65. package/src/add/add-schedule.ts +0 -120
  66. package/src/add/add-scope.test.ts +0 -419
  67. package/src/add/add-scope.ts +0 -221
  68. package/src/add/add-secret.ts +0 -10
  69. package/src/add/add-subject-scenario-steps.test.ts +0 -161
  70. package/src/add/add-system-role.test.ts +0 -297
  71. package/src/add/add-system-role.ts +0 -162
  72. package/src/add/add-trigger.ts +0 -165
  73. package/src/add/add-variable.ts +0 -10
  74. package/src/add/add-wire-addon.test.ts +0 -185
  75. package/src/add/add-wire-addon.ts +0 -146
  76. package/src/add/add-wire-remote-addon.ts +0 -80
  77. package/src/add/add-workflow-edge-cases.test.ts +0 -201
  78. package/src/add/add-workflow-fanout.test.ts +0 -166
  79. package/src/add/add-workflow-graph-foreach.test.ts +0 -174
  80. package/src/add/add-workflow-graph-input.test.ts +0 -94
  81. package/src/add/add-workflow-graph-notes.test.ts +0 -74
  82. package/src/add/add-workflow-graph.ts +0 -644
  83. package/src/add/add-workflow-on-error.test.ts +0 -118
  84. package/src/add/add-workflow-return-switch.test.ts +0 -121
  85. package/src/add/add-workflow-silent-drops.test.ts +0 -261
  86. package/src/add/add-workflow-sleep-expression.test.ts +0 -96
  87. package/src/add/add-workflow.test.ts +0 -155
  88. package/src/add/add-workflow.ts +0 -482
  89. package/src/add/addon-bans.test.ts +0 -121
  90. package/src/add/addon-contracts.test.ts +0 -221
  91. package/src/add/allow-escape-hatches.test.ts +0 -144
  92. package/src/add/audit-meta.test.ts +0 -75
  93. package/src/add/classified-sink-check.test.ts +0 -347
  94. package/src/add/config-value-metadata.test.ts +0 -263
  95. package/src/add/dynamic-import-check.test.ts +0 -119
  96. package/src/add/expect-eventually-scenario-only.test.ts +0 -108
  97. package/src/add/function-body-span.test.ts +0 -96
  98. package/src/add/inline-wiring-function.test.ts +0 -104
  99. package/src/add/pii-check.test.ts +0 -258
  100. package/src/add/rpc-type-cast.test.ts +0 -123
  101. package/src/add/scenario-instrumentation.test.ts +0 -89
  102. package/src/add/scenario-instrumentation.ts +0 -42
  103. package/src/add/scenario-marker.test.ts +0 -89
  104. package/src/add/scenario-not-extractable.test.ts +0 -159
  105. package/src/add/scenario-services.test.ts +0 -103
  106. package/src/add/scenario-skip.test.ts +0 -83
  107. package/src/add/validate-workflow-graph-addons.test.ts +0 -102
  108. package/src/add/wire-name-literal.test.ts +0 -117
  109. package/src/error-codes-emitted.test.ts +0 -76
  110. package/src/error-codes.ts +0 -145
  111. package/src/index.ts +0 -66
  112. package/src/inspector.ts +0 -575
  113. package/src/schema-generator.ts +0 -1
  114. package/src/source-file-cache.ts +0 -73
  115. package/src/tsconfig-paths.test.ts +0 -90
  116. package/src/types-map.ts +0 -141
  117. package/src/types.ts +0 -775
  118. package/src/utils/annotate-http-route-auth.test.ts +0 -161
  119. package/src/utils/annotate-http-route-auth.ts +0 -53
  120. package/src/utils/check-classified-sinks.ts +0 -215
  121. package/src/utils/check-pii-output.ts +0 -120
  122. package/src/utils/compute-diagnostics.test.ts +0 -106
  123. package/src/utils/compute-required-schemas.ts +0 -48
  124. package/src/utils/contract-hashes.test.ts +0 -553
  125. package/src/utils/contract-hashes.ts +0 -387
  126. package/src/utils/custom-types-generator.test.ts +0 -124
  127. package/src/utils/custom-types-generator.ts +0 -150
  128. package/src/utils/detect-schema-vendor.test.ts +0 -123
  129. package/src/utils/detect-schema-vendor.ts +0 -120
  130. package/src/utils/does-type-extend-core-type.ts +0 -53
  131. package/src/utils/ensure-function-metadata.ts +0 -385
  132. package/src/utils/extract-function-name.ts +0 -612
  133. package/src/utils/extract-function-node.ts +0 -70
  134. package/src/utils/extract-node-value.test.ts +0 -117
  135. package/src/utils/extract-node-value.ts +0 -172
  136. package/src/utils/extract-secret-usage.test.ts +0 -81
  137. package/src/utils/extract-secret-usage.ts +0 -73
  138. package/src/utils/extract-services.ts +0 -70
  139. package/src/utils/extract-surface-usage.test.ts +0 -147
  140. package/src/utils/extract-surface-usage.ts +0 -121
  141. package/src/utils/filter-inspector-state.test.ts +0 -2207
  142. package/src/utils/filter-inspector-state.ts +0 -1302
  143. package/src/utils/filter-utils.test.ts +0 -843
  144. package/src/utils/filter-utils.ts +0 -161
  145. package/src/utils/find-root-dir.ts +0 -68
  146. package/src/utils/find-zod-transform.test.ts +0 -70
  147. package/src/utils/find-zod-transform.ts +0 -103
  148. package/src/utils/get-exported-variable-name.ts +0 -48
  149. package/src/utils/get-files-and-methods.ts +0 -157
  150. package/src/utils/get-property-value.test.ts +0 -141
  151. package/src/utils/get-property-value.ts +0 -331
  152. package/src/utils/hash.ts +0 -26
  153. package/src/utils/literal-properties.ts +0 -97
  154. package/src/utils/load-addon-functions-meta.test.ts +0 -157
  155. package/src/utils/load-addon-functions-meta.ts +0 -508
  156. package/src/utils/load-addon-scopes.test.ts +0 -138
  157. package/src/utils/middleware.test.ts +0 -204
  158. package/src/utils/middleware.ts +0 -303
  159. package/src/utils/nested-project-filter.test.ts +0 -53
  160. package/src/utils/nested-project-filter.ts +0 -28
  161. package/src/utils/permissions-shorthand.test.ts +0 -79
  162. package/src/utils/permissions.test.ts +0 -125
  163. package/src/utils/permissions.ts +0 -129
  164. package/src/utils/post-process.test.ts +0 -1094
  165. package/src/utils/post-process.ts +0 -1604
  166. package/src/utils/resolve-addon-package.ts +0 -106
  167. package/src/utils/resolve-core-type.test.ts +0 -142
  168. package/src/utils/resolve-core-type.ts +0 -35
  169. package/src/utils/resolve-deploy-target.test.ts +0 -135
  170. package/src/utils/resolve-deploy-target.ts +0 -65
  171. package/src/utils/resolve-function-meta.ts +0 -25
  172. package/src/utils/resolve-function-types.ts +0 -42
  173. package/src/utils/resolve-identifier.ts +0 -46
  174. package/src/utils/resolve-ref-contract.ts +0 -71
  175. package/src/utils/resolve-versions.test.ts +0 -499
  176. package/src/utils/resolve-versions.ts +0 -228
  177. package/src/utils/schema-cache.test.ts +0 -124
  178. package/src/utils/schema-generator.test.ts +0 -272
  179. package/src/utils/schema-generator.ts +0 -780
  180. package/src/utils/secret-alias-services.test.ts +0 -146
  181. package/src/utils/secret-alias-services.ts +0 -58
  182. package/src/utils/serialize-inspector-state.ts +0 -742
  183. package/src/utils/serialize-mcp-json.ts +0 -150
  184. package/src/utils/serialize-middleware-groups-meta.ts +0 -36
  185. package/src/utils/serialize-openapi-json.ts +0 -277
  186. package/src/utils/serialize-permissions-groups-meta.ts +0 -7
  187. package/src/utils/single-declaration.ts +0 -57
  188. package/src/utils/type-utils.ts +0 -145
  189. package/src/utils/validate-auth-sessionless.ts +0 -30
  190. package/src/utils/validate-exposed-functions-gated.test.ts +0 -230
  191. package/src/utils/validate-exposed-functions-gated.ts +0 -103
  192. package/src/utils/validate-tags-resolve-to-middleware.test.ts +0 -108
  193. package/src/utils/validate-tags-resolve-to-middleware.ts +0 -48
  194. package/src/utils/workflow/derive-workflow-plan.test.ts +0 -178
  195. package/src/utils/workflow/derive-workflow-plan.ts +0 -117
  196. package/src/utils/workflow/dsl/deserialize-dsl-workflow.ts +0 -1465
  197. package/src/utils/workflow/dsl/deserialize-fanout-body.test.ts +0 -71
  198. package/src/utils/workflow/dsl/deserialize-fanout-fidelity.test.ts +0 -99
  199. package/src/utils/workflow/dsl/deserialize-graph-next.test.ts +0 -161
  200. package/src/utils/workflow/dsl/deserialize-roundtrip-bindings.test.ts +0 -121
  201. package/src/utils/workflow/dsl/deserialize-roundtrip-values.test.ts +0 -116
  202. package/src/utils/workflow/dsl/deserialize-roundtrip.test.ts +0 -145
  203. package/src/utils/workflow/dsl/extract-approval-policy.test.ts +0 -71
  204. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +0 -2254
  205. package/src/utils/workflow/dsl/index.ts +0 -11
  206. package/src/utils/workflow/dsl/patterns.ts +0 -440
  207. package/src/utils/workflow/dsl/validation.ts +0 -203
  208. package/src/utils/workflow/graph/convert-dsl-to-graph-skip.test.ts +0 -35
  209. package/src/utils/workflow/graph/convert-dsl-to-graph.ts +0 -543
  210. package/src/utils/workflow/graph/fanout-node-identity.test.ts +0 -105
  211. package/src/utils/workflow/graph/finalize-workflow-wires.ts +0 -310
  212. package/src/utils/workflow/graph/finalize-workflows.test.ts +0 -50
  213. package/src/utils/workflow/graph/finalize-workflows.ts +0 -130
  214. package/src/utils/workflow/graph/index.ts +0 -28
  215. package/src/utils/workflow/graph/serialize-workflow-graph.ts +0 -240
  216. package/src/utils/workflow/graph/workflow-graph.types.ts +0 -281
  217. package/src/visit.ts +0 -181
  218. package/tsconfig.json +0 -19
  219. package/tsconfig.tsbuildinfo +0 -1
@@ -1,2254 +0,0 @@
1
- import * as ts from 'typescript'
2
- import type {
3
- WorkflowStepMeta,
4
- RpcStepMeta,
5
- InlineStepMeta,
6
- BranchStepMeta,
7
- ParallelGroupStepMeta,
8
- FanoutStepMeta,
9
- CancelStepMeta,
10
- SuspendStepMeta,
11
- SleepStepMeta,
12
- ApprovalStepMeta,
13
- SetStepMeta,
14
- SwitchStepMeta,
15
- SwitchCaseMeta,
16
- FilterStepMeta,
17
- ArrayPredicateStepMeta,
18
- InputSource,
19
- OutputBinding,
20
- Condition,
21
- WorkflowContext,
22
- ContextVariable,
23
- } from '@pikku/core/workflow'
24
- import type { ScenarioStepMeta } from '@pikku/core/scenario'
25
- import {
26
- isWorkflowDoCall,
27
- isWorkflowExpectEventuallyCall,
28
- isScenarioStepCall,
29
- isScenarioExpectationCall,
30
- getScenarioStepPhase,
31
- DYNAMIC_SCENARIO_STEP_TARGET,
32
- extractActorFromOptions,
33
- isWorkflowSleepCall,
34
- isWorkflowSuspendCall,
35
- isWorkflowApprovalCall,
36
- isThrowCancelException,
37
- extractCancelReason,
38
- isParallelFanout,
39
- isParallelGroup,
40
- isSequentialFanout,
41
- isArrayFilter,
42
- isArraySome,
43
- isArrayEvery,
44
- extractForOfVariable,
45
- isArrayType,
46
- getSourceText,
47
- extractSourcePath,
48
- } from './patterns.js'
49
- import type { ValidationError } from './validation.js'
50
- import {
51
- validateNoDisallowedPatterns,
52
- validateAwaitedCalls,
53
- formatValidationErrors,
54
- } from './validation.js'
55
- import {
56
- extractStringLiteral,
57
- extractNumberLiteral,
58
- } from '../../extract-node-value.js'
59
-
60
- /**
61
- * Extraction context to track state during AST traversal
62
- */
63
- interface ExtractionContext {
64
- checker: ts.TypeChecker
65
- outputVars: Map<string, { type: ts.Type; node: ts.Node }>
66
- arrayVars: Set<string>
67
- conditionalVars: Set<string>
68
- inputParamName: string | null
69
- errors: ValidationError[]
70
- /** Loop variables in scope (for fanout item variables) */
71
- loopVars: Set<string>
72
- /** Context variables (top-level let/const with simple values) */
73
- contextVars: Map<string, { type: string; default: unknown }>
74
- /** Track nesting depth to detect block-scoped vars */
75
- depth: number
76
- }
77
-
78
- /**
79
- * Result of DSL workflow extraction
80
- */
81
- export interface ExtractionResult {
82
- status: 'ok' | 'error'
83
- steps?: WorkflowStepMeta[]
84
- /** Workflow context (top-level variables) */
85
- context?: WorkflowContext
86
- /**
87
- * The flow asserts something through an expectation helper rather than a
88
- * `then` step. Recorded separately because those helpers are inline steps and
89
- * leave no phase behind, so PKU680 has no other way to see them.
90
- */
91
- asserts?: boolean
92
- reason?: string
93
- }
94
-
95
- /**
96
- * A function the walk must not step into, because nothing here says it runs.
97
- *
98
- * A callback handed straight to a call — a fanout's `map`, an inline
99
- * `workflow.do` step — executes as part of the body and counts. A function that
100
- * is merely *declared* or assigned to a name does not: an assertion parked in an
101
- * unused helper asserts nothing, and counting it would let a scenario silence
102
- * PKU680 with dead code.
103
- */
104
- function isUncalledFunction(node: ts.Node): boolean {
105
- if (
106
- !ts.isFunctionDeclaration(node) &&
107
- !ts.isFunctionExpression(node) &&
108
- !ts.isArrowFunction(node)
109
- ) {
110
- return false
111
- }
112
- if (ts.isFunctionDeclaration(node)) return true
113
- const parent = node.parent
114
- return !(
115
- parent &&
116
- ts.isCallExpression(parent) &&
117
- parent.arguments.some((argument) => argument === node)
118
- )
119
- }
120
-
121
- /**
122
- * Whether the body calls one of the expectation helpers anywhere it runs.
123
- *
124
- * A whole-body walk rather than a check inside step extraction: the helpers
125
- * produce no step of their own, so there is no extraction result to hang the
126
- * answer off, and one nested inside a branch asserts just as much as one at the
127
- * top level.
128
- */
129
- function hasScenarioExpectation(body: ts.Node): boolean {
130
- let found = false
131
- const visit = (node: ts.Node): void => {
132
- if (found) return
133
- if (ts.isCallExpression(node) && isScenarioExpectationCall(node)) {
134
- found = true
135
- return
136
- }
137
- if (node !== body && isUncalledFunction(node)) return
138
- ts.forEachChild(node, visit)
139
- }
140
- visit(body)
141
- return found
142
- }
143
-
144
- /**
145
- * Extract DSL workflow metadata from a function declaration
146
- */
147
- export function extractDSLWorkflow(
148
- funcNode: ts.Node,
149
- checker: ts.TypeChecker,
150
- options?: { allowInline?: boolean }
151
- ): ExtractionResult {
152
- try {
153
- // Find the async arrow function
154
- const arrowFunc = findWorkflowFunction(funcNode)
155
- if (!arrowFunc) {
156
- return {
157
- status: 'error',
158
- reason: 'Could not find async arrow function in workflow definition',
159
- }
160
- }
161
-
162
- // Extract input parameter name (second parameter)
163
- const inputParamName = extractInputParamName(arrowFunc)
164
- if (inputParamName === undefined) {
165
- return {
166
- status: 'error',
167
- reason: 'Could not determine input parameter name',
168
- }
169
- }
170
-
171
- // Initialize extraction context
172
- const context: ExtractionContext = {
173
- checker,
174
- outputVars: new Map(),
175
- arrayVars: new Set(),
176
- conditionalVars: new Set(),
177
- inputParamName,
178
- errors: [],
179
- loopVars: new Set(),
180
- contextVars: new Map(),
181
- depth: 0,
182
- }
183
-
184
- // Validate no disallowed patterns
185
- const patternErrors = validateNoDisallowedPatterns(arrowFunc.body, {
186
- allowInline: options?.allowInline,
187
- })
188
- if (patternErrors.length > 0) {
189
- return {
190
- status: 'error',
191
- reason: formatValidationErrors(patternErrors),
192
- }
193
- }
194
-
195
- // Validate all workflow calls are awaited
196
- const awaitErrors = validateAwaitedCalls(arrowFunc.body)
197
- if (awaitErrors.length > 0) {
198
- return {
199
- status: 'error',
200
- reason: formatValidationErrors(awaitErrors),
201
- }
202
- }
203
-
204
- // Extract steps from function body
205
- const steps = extractSteps(arrowFunc.body, context)
206
-
207
- // Check for any accumulated errors
208
- if (context.errors.length > 0) {
209
- return {
210
- status: 'error',
211
- reason: formatValidationErrors(context.errors),
212
- }
213
- }
214
-
215
- // Build workflow context from extracted context variables
216
- const workflowContext: WorkflowContext = {}
217
- for (const [name, info] of context.contextVars) {
218
- workflowContext[name] = {
219
- type: info.type as ContextVariable['type'],
220
- default: info.default,
221
- }
222
- }
223
-
224
- return {
225
- status: 'ok',
226
- steps,
227
- context:
228
- Object.keys(workflowContext).length > 0 ? workflowContext : undefined,
229
- asserts: hasScenarioExpectation(arrowFunc.body) || undefined,
230
- }
231
- } catch (error) {
232
- return {
233
- status: 'error',
234
- reason: error instanceof Error ? error.message : String(error),
235
- }
236
- }
237
- }
238
-
239
- /**
240
- * Find the workflow function (async arrow function)
241
- */
242
- function findWorkflowFunction(node: ts.Node): ts.ArrowFunction | null {
243
- // Handle pikkuWorkflowFunc(async () => {}) or pikkuWorkflowComplexFunc(async () => {})
244
- if (ts.isCallExpression(node)) {
245
- const arg = node.arguments[0]
246
- if (arg && ts.isArrowFunction(arg)) {
247
- return arg
248
- }
249
- // Also check if first argument is an object with func property
250
- if (arg && ts.isObjectLiteralExpression(arg)) {
251
- for (const prop of arg.properties) {
252
- if (
253
- ts.isPropertyAssignment(prop) &&
254
- ts.isIdentifier(prop.name) &&
255
- prop.name.text === 'func'
256
- ) {
257
- if (ts.isArrowFunction(prop.initializer)) {
258
- return prop.initializer
259
- }
260
- }
261
- }
262
- }
263
- }
264
-
265
- // Handle pikkuWorkflowFunc({ func: async () => {} })
266
- if (ts.isObjectLiteralExpression(node)) {
267
- for (const prop of node.properties) {
268
- if (
269
- ts.isPropertyAssignment(prop) &&
270
- ts.isIdentifier(prop.name) &&
271
- prop.name.text === 'func'
272
- ) {
273
- if (ts.isArrowFunction(prop.initializer)) {
274
- return prop.initializer
275
- }
276
- }
277
- }
278
- }
279
-
280
- return null
281
- }
282
-
283
- /**
284
- * Extract the input parameter name from the arrow function.
285
- *
286
- * Returns `null` when no input parameter is declared at all. That is
287
- * legitimate — a workflow may ignore its input — and nothing in the body can
288
- * reference it, so every name comparison against `null` correctly fails.
289
- * Returns `undefined` when an input parameter is declared but is not a plain
290
- * identifier (destructured), which the extractor cannot track by name.
291
- */
292
- function extractInputParamName(
293
- arrowFunc: ts.ArrowFunction
294
- ): string | null | undefined {
295
- if (arrowFunc.parameters.length < 2) {
296
- return null
297
- }
298
-
299
- const secondParam = arrowFunc.parameters[1]
300
- if (ts.isIdentifier(secondParam.name)) {
301
- return secondParam.name.text
302
- }
303
-
304
- return undefined
305
- }
306
-
307
- /**
308
- * Extract steps from the function body
309
- */
310
- function extractSteps(
311
- body: ts.Node,
312
- context: ExtractionContext,
313
- incrementDepth = false
314
- ): WorkflowStepMeta[] {
315
- const steps: WorkflowStepMeta[] = []
316
-
317
- if (!ts.isBlock(body)) {
318
- return steps
319
- }
320
-
321
- // Increment depth when entering a nested block
322
- if (incrementDepth) {
323
- context.depth++
324
- }
325
-
326
- for (const statement of body.statements) {
327
- const extracted = extractStep(statement, context)
328
- if (extracted) {
329
- steps.push(extracted)
330
- }
331
- }
332
-
333
- // Restore depth
334
- if (incrementDepth) {
335
- context.depth--
336
- }
337
-
338
- return steps
339
- }
340
-
341
- /**
342
- * Extract a single step from a statement
343
- */
344
- function extractStep(
345
- statement: ts.Statement,
346
- context: ExtractionContext
347
- ): WorkflowStepMeta | null {
348
- // Variable declaration with workflow.do assignment
349
- if (ts.isVariableStatement(statement)) {
350
- return extractVariableDeclaration(statement, context)
351
- }
352
-
353
- // Expression statement (await workflow.do without assignment)
354
- if (ts.isExpressionStatement(statement)) {
355
- return extractExpressionStatement(statement, context)
356
- }
357
-
358
- // If statement (branch)
359
- if (ts.isIfStatement(statement)) {
360
- return extractBranch(statement, context)
361
- }
362
-
363
- // Switch statement
364
- if (ts.isSwitchStatement(statement)) {
365
- return extractSwitch(statement, context)
366
- }
367
-
368
- // For-of statement (sequential fanout)
369
- if (ts.isForOfStatement(statement)) {
370
- const reason: { message?: string } = {}
371
- const fanout = extractSequentialFanout(statement, context, reason)
372
- if (!fanout && containsWorkflowCall(statement.statement, context.checker)) {
373
- // A for-of the DSL can't model as a sequential fanout (its iterable is not
374
- // a data-array identifier/field — e.g. a counting loop
375
- // `for (const i of [...Array(N).keys()])`). Silently returning null here
376
- // used to drop the loop AND every `workflow.do` inside it, so the workflow
377
- // serialized with zero steps and its step functions were never registered
378
- // (→ runtime "Function not found"). Fail loudly instead: pikkuWorkflowFunc
379
- // reports INVALID_DSL_WORKFLOW; pikkuWorkflowComplexFunc falls back to the
380
- // basic AST walk (which DOES register loop-invoked functions), so a genuine
381
- // control-flow loop belongs in a complex workflow.
382
- //
383
- // Guarded on the body actually containing a workflow call: a loop that only
384
- // massages locals (building a lookup, summing a total) has no step to lose,
385
- // and erroring on it would be a false positive — the whole hazard is a
386
- // dropped `workflow.do`, not a dropped `for`.
387
- context.errors.push({
388
- message:
389
- `The for-of loop '${statement.getText().slice(0, 60)}' can't be expressed in a DSL workflow — ` +
390
- (reason.message ??
391
- `its iterable must be a data array (an identifier or field like 'data.items'), not a computed/inline ` +
392
- `iterable such as '[...Array(n).keys()]'. Use pikkuWorkflowComplexFunc for a control-flow/counting loop, ` +
393
- `or iterate over a real input array.`),
394
- node: statement,
395
- })
396
- }
397
- return fanout
398
- }
399
-
400
- // Return statement
401
- if (ts.isReturnStatement(statement)) {
402
- return extractReturn(statement, context)
403
- }
404
-
405
- // Throw statement (for WorkflowCancelledException)
406
- if (ts.isThrowStatement(statement)) {
407
- return extractThrowCancel(statement, context)
408
- }
409
-
410
- return null
411
- }
412
-
413
- /**
414
- * Extract variable declaration (const x = await workflow.do(...))
415
- */
416
- function extractVariableDeclaration(
417
- statement: ts.VariableStatement,
418
- context: ExtractionContext
419
- ): WorkflowStepMeta | null {
420
- const declList = statement.declarationList
421
- if (declList.declarations.length !== 1) {
422
- context.errors.push({
423
- message: `A single declaration statement may only declare one variable in DSL workflows. Split '${statement.getText().slice(0, 60)}' into separate statements.`,
424
- node: statement,
425
- })
426
- return null
427
- }
428
-
429
- const decl = declList.declarations[0]
430
- if (!ts.isIdentifier(decl.name)) {
431
- return extractDestructuredDeclaration(statement, decl, context)
432
- }
433
-
434
- const varName = decl.name.text
435
- const init = decl.initializer
436
-
437
- // Check for block-scoped variable declarations (not allowed)
438
- if (context.depth > 0) {
439
- context.errors.push({
440
- message: `Variable declaration '${varName}' inside block is not supported in DSL workflows. Move all let/const declarations to the top level.`,
441
- node: statement,
442
- })
443
- return null
444
- }
445
-
446
- if (!init) {
447
- return null
448
- }
449
-
450
- // Check for simple literal/expression context variable (let x = 'value')
451
- const literalValue = extractLiteralValue(init)
452
- if (literalValue !== undefined) {
453
- const tsType = context.checker.getTypeAtLocation(decl)
454
- const typeStr = inferSimpleType(tsType, context.checker)
455
- context.contextVars.set(varName, { type: typeStr, default: literalValue })
456
- return null // No step emitted, just register the context var
457
- }
458
-
459
- // Check for await workflow.do(...)
460
- if (ts.isAwaitExpression(init) && ts.isCallExpression(init.expression)) {
461
- const call = init.expression
462
- if (isWorkflowDoCall(call, context.checker) || isScenarioStepCall(call)) {
463
- const step = isScenarioStepCall(call)
464
- ? extractScenarioStep(call, context, varName)
465
- : isInlineDoCall(call)
466
- ? extractInlineStep(call, context)
467
- : extractRpcStep(call, context, varName)
468
- if (step) {
469
- // Track output variable
470
- const type = context.checker.getTypeAtLocation(decl)
471
- context.outputVars.set(varName, { type, node: decl })
472
-
473
- // Check if it's an array type
474
- if (isArrayType(type, context.checker)) {
475
- context.arrayVars.add(varName)
476
- }
477
-
478
- // Check if it's a conditional variable (let x: T | undefined)
479
- if (declList.flags & ts.NodeFlags.Let) {
480
- const typeNode = decl.type
481
- if (typeNode && ts.isUnionTypeNode(typeNode)) {
482
- // Check if union includes undefined
483
- const hasUndefined = typeNode.types.some(
484
- (t) =>
485
- (ts.isLiteralTypeNode(t) &&
486
- t.literal.kind === ts.SyntaxKind.UndefinedKeyword) ||
487
- t.kind === ts.SyntaxKind.UndefinedKeyword
488
- )
489
- if (hasUndefined) {
490
- context.conditionalVars.add(varName)
491
- }
492
- }
493
- }
494
-
495
- return step
496
- }
497
- }
498
-
499
- // Promise.all fanout/group captured into a variable
500
- // (const results = await Promise.all(array.map(...)))
501
- if (isParallelFanout(call) || isParallelGroup(call)) {
502
- const step = isParallelFanout(call)
503
- ? extractParallelFanout(call, context)
504
- : extractParallelGroup(call, context)
505
- if (step) {
506
- const type = context.checker.getTypeAtLocation(decl)
507
- context.outputVars.set(varName, { type, node: decl })
508
- if (isArrayType(type, context.checker)) {
509
- context.arrayVars.add(varName)
510
- }
511
- return step
512
- }
513
- }
514
- }
515
-
516
- // Check for array.filter(...)
517
- if (ts.isCallExpression(init)) {
518
- if (isArrayFilter(init)) {
519
- const filterStep = extractArrayFilter(init, context, varName)
520
- if (filterStep) {
521
- const type = context.checker.getTypeAtLocation(decl)
522
- context.outputVars.set(varName, { type, node: decl })
523
- if (isArrayType(type, context.checker)) {
524
- context.arrayVars.add(varName)
525
- }
526
- return filterStep
527
- }
528
- }
529
-
530
- if (isArraySome(init) || isArrayEvery(init)) {
531
- const predicateStep = extractArrayPredicate(init, context, varName)
532
- if (predicateStep) {
533
- const type = context.checker.getTypeAtLocation(decl)
534
- context.outputVars.set(varName, { type, node: decl })
535
- return predicateStep
536
- }
537
- }
538
- }
539
-
540
- return null
541
- }
542
-
543
- /**
544
- * Extract a declaration whose binding is a destructuring pattern.
545
- *
546
- * `const [a, b] = await Promise.all([...])` is the idiomatic way to run steps
547
- * in parallel and keep both results, so each element of the pattern is bound to
548
- * the matching child step's output. Every other destructuring OF A STEP reports a
549
- * diagnostic rather than silently dropping the step.
550
- *
551
- * A destructure whose initializer is NOT a step is just an ordinary local binding
552
- * — `const { runId } = input`, `const { a } = someObject` — and has nothing to do
553
- * with step results. Those pass through as non-steps, exactly as the identifier
554
- * path already does for `const x = someLocal`. Reporting them was rejecting the
555
- * single most idiomatic line in a DSL workflow (destructuring the workflow's own
556
- * input), under a message about step results that named nothing the author wrote.
557
- */
558
- function extractDestructuredDeclaration(
559
- statement: ts.VariableStatement,
560
- decl: ts.VariableDeclaration,
561
- context: ExtractionContext
562
- ): WorkflowStepMeta | null {
563
- const init = decl.initializer
564
-
565
- if (
566
- init &&
567
- ts.isAwaitExpression(init) &&
568
- ts.isCallExpression(init.expression) &&
569
- isParallelGroup(init.expression) &&
570
- ts.isArrayBindingPattern(decl.name)
571
- ) {
572
- const elements = decl.name.elements
573
- const allSimple = elements.every(
574
- (el) =>
575
- ts.isBindingElement(el) &&
576
- ts.isIdentifier(el.name) &&
577
- !el.dotDotDotToken
578
- )
579
-
580
- if (allSimple) {
581
- const step = extractParallelGroup(init.expression, context)
582
- if (step) {
583
- if (elements.length !== step.children.length) {
584
- context.errors.push({
585
- message: `Destructuring binds ${elements.length} name(s) but Promise.all has ${step.children.length} step(s). They must match so each result can be bound to its step.`,
586
- node: statement,
587
- })
588
- return null
589
- }
590
-
591
- elements.forEach((el, i) => {
592
- const name = (el as ts.BindingElement).name as ts.Identifier
593
- const varName = name.text
594
- step.children[i].outputVar = varName
595
- const type = context.checker.getTypeAtLocation(name)
596
- context.outputVars.set(varName, { type, node: name })
597
- if (isArrayType(type, context.checker)) {
598
- context.arrayVars.add(varName)
599
- }
600
- })
601
-
602
- return step
603
- }
604
- }
605
- }
606
-
607
- // Only a destructure OF A STEP is an error. Anything else is a plain local
608
- // binding the DSL simply doesn't model as a step.
609
- const stepCall =
610
- init && ts.isAwaitExpression(init) && ts.isCallExpression(init.expression)
611
- ? init.expression
612
- : null
613
- const destructuresAStep =
614
- stepCall !== null &&
615
- (isWorkflowDoCall(stepCall, context.checker) ||
616
- isScenarioStepCall(stepCall) ||
617
- isParallelGroup(stepCall) ||
618
- isParallelFanout(stepCall))
619
- if (!destructuresAStep) return null
620
-
621
- context.errors.push({
622
- message: `Destructuring a step result is not supported in DSL workflows. Assign it to a variable first (e.g. \`const result = await workflow.do(...)\`) and read its properties.`,
623
- node: statement,
624
- })
625
- return null
626
- }
627
-
628
- /**
629
- * Extract expression statement (await workflow.do(...) without assignment)
630
- */
631
- function extractExpressionStatement(
632
- statement: ts.ExpressionStatement,
633
- context: ExtractionContext
634
- ): WorkflowStepMeta | null {
635
- let expr = statement.expression
636
-
637
- // Handle assignment: x = value or x = await workflow.do(...)
638
- let outputVar: string | undefined
639
- if (
640
- ts.isBinaryExpression(expr) &&
641
- expr.operatorToken.kind === ts.SyntaxKind.EqualsToken
642
- ) {
643
- // Extract variable name from left side
644
- if (ts.isIdentifier(expr.left)) {
645
- outputVar = expr.left.text
646
-
647
- // Check if this is an assignment to a context variable (set step)
648
- // But if the RHS is a workflow.do() call, fall through to RPC extraction —
649
- // reassigning a pre-declared variable with a workflow step is valid and common.
650
- if (context.contextVars.has(outputVar)) {
651
- const rhs = expr.right
652
- const rhsCall =
653
- ts.isAwaitExpression(rhs) && ts.isCallExpression(rhs.expression)
654
- ? rhs.expression
655
- : null
656
- const isWorkflowCall = rhsCall
657
- ? isWorkflowDoCall(rhsCall, context.checker)
658
- : false
659
-
660
- if (!isWorkflowCall) {
661
- const literalValue = extractLiteralValue(expr.right)
662
- if (literalValue !== undefined) {
663
- return {
664
- type: 'set',
665
- variable: outputVar,
666
- value: literalValue,
667
- } as SetStepMeta
668
- }
669
- // Non-literal assignment to context var - keep the source expression
670
- return {
671
- type: 'set',
672
- variable: outputVar,
673
- expression: getSourceText(expr.right),
674
- } as SetStepMeta
675
- }
676
- }
677
- }
678
- // Use right side as the expression to extract from
679
- expr = expr.right
680
- }
681
-
682
- // await workflow.do(...)
683
- if (ts.isAwaitExpression(expr) && ts.isCallExpression(expr.expression)) {
684
- const call = expr.expression
685
-
686
- if (isWorkflowDoCall(call, context.checker) || isScenarioStepCall(call)) {
687
- const step = isScenarioStepCall(call)
688
- ? extractScenarioStep(call, context, outputVar)
689
- : isInlineDoCall(call)
690
- ? extractInlineStep(call, context)
691
- : extractRpcStep(call, context, outputVar)
692
-
693
- // Track output variable if this is an assignment
694
- if (outputVar && step) {
695
- const type = context.checker.getTypeAtLocation(expr)
696
- context.outputVars.set(outputVar, { type, node: expr })
697
-
698
- // Check if it's an array type
699
- if (isArrayType(type, context.checker)) {
700
- context.arrayVars.add(outputVar)
701
- }
702
- }
703
-
704
- return step
705
- }
706
-
707
- if (isWorkflowSleepCall(call, context.checker)) {
708
- return extractSleepStep(call, context)
709
- }
710
-
711
- if (isWorkflowSuspendCall(call, context.checker)) {
712
- return extractSuspendStep(call, context)
713
- }
714
-
715
- if (isWorkflowApprovalCall(call, context.checker)) {
716
- const step = extractApprovalStep(call, context, outputVar)
717
-
718
- // Unlike suspend, an approval yields a value, so a downstream step can
719
- // reference it — track the binding the same way workflow.do() does.
720
- if (outputVar && step) {
721
- const type = context.checker.getTypeAtLocation(expr)
722
- context.outputVars.set(outputVar, { type, node: expr })
723
- if (isArrayType(type, context.checker)) {
724
- context.arrayVars.add(outputVar)
725
- }
726
- }
727
-
728
- return step
729
- }
730
-
731
- // Check for parallel group or fanout
732
- if (isParallelFanout(call)) {
733
- return extractParallelFanout(call, context)
734
- }
735
-
736
- if (isParallelGroup(call)) {
737
- return extractParallelGroup(call, context)
738
- }
739
- }
740
-
741
- return null
742
- }
743
-
744
- /**
745
- * Extract RPC step from workflow.do() call
746
- */
747
- function extractRpcStep(
748
- call: ts.CallExpression,
749
- context: ExtractionContext,
750
- outputVar?: string
751
- ): RpcStepMeta | null {
752
- const args = call.arguments
753
-
754
- if (args.length < 2) {
755
- return null
756
- }
757
-
758
- try {
759
- const stepName = extractStringLiteral(args[0], context.checker)
760
- const rpcName = extractStringLiteral(args[1], context.checker)
761
-
762
- // Extract inputs from third argument
763
- const inputs =
764
- args.length >= 3 ? extractInputSources(args[2], context) : undefined
765
-
766
- // do(step, rpc, data, options?) vs expectEventually(step, rpc, data, predicate, options?)
767
- const expectEventually = isWorkflowExpectEventuallyCall(call)
768
- const optionsIndex = expectEventually ? 4 : 3
769
- const optionsArg =
770
- args.length > optionsIndex ? args[optionsIndex] : undefined
771
-
772
- const options =
773
- optionsArg && ts.isObjectLiteralExpression(optionsArg)
774
- ? extractStepOptions(optionsArg, context)
775
- : undefined
776
-
777
- return {
778
- type: 'rpc',
779
- stepName,
780
- rpcName,
781
- outputVar,
782
- inputs,
783
- options,
784
- actor: extractActorFromOptions(optionsArg),
785
- expectEventually: expectEventually || undefined,
786
- }
787
- } catch (error) {
788
- context.errors.push({
789
- message: `Failed to extract RPC step: ${error instanceof Error ? error.message : String(error)}`,
790
- node: call,
791
- })
792
- return null
793
- }
794
- }
795
-
796
- /**
797
- * Extract a scenario step from scenario.step/given/when/then(stepName,
798
- * stepFunc, data?, options?).
799
- *
800
- * The step target must be a static string literal so it can be bundled, typed
801
- * and drawn. When it isn't, the step is still recorded — with a `<dynamic>`
802
- * target — so post-processing can raise PKU678 rather than the call silently
803
- * vanishing from the graph.
804
- */
805
- function extractScenarioStep(
806
- call: ts.CallExpression,
807
- context: ExtractionContext,
808
- outputVar?: string
809
- ): ScenarioStepMeta | null {
810
- const phase = getScenarioStepPhase(call)
811
- if (!phase) {
812
- return null
813
- }
814
-
815
- const args = call.arguments
816
- if (args.length < 2) {
817
- return null
818
- }
819
-
820
- try {
821
- const stepName = extractStringLiteral(args[0], context.checker)
822
-
823
- let stepFunc = DYNAMIC_SCENARIO_STEP_TARGET
824
- try {
825
- stepFunc = extractStringLiteral(args[1], context.checker)
826
- } catch {
827
- // Left as '<dynamic>' — validated in post-processing (PKU678).
828
- }
829
-
830
- const inputs =
831
- args.length >= 3 ? extractInputSources(args[2], context) : undefined
832
-
833
- const optionsArg = args.length >= 4 ? args[3] : undefined
834
- const options =
835
- optionsArg && ts.isObjectLiteralExpression(optionsArg)
836
- ? extractStepOptions(optionsArg, context)
837
- : undefined
838
-
839
- return {
840
- type: 'scenarioStep',
841
- stepName,
842
- stepFunc,
843
- phase,
844
- outputVar,
845
- inputs,
846
- options,
847
- actor: extractActorFromOptions(optionsArg),
848
- }
849
- } catch (error) {
850
- context.errors.push({
851
- message: `Failed to extract scenario step: ${error instanceof Error ? error.message : String(error)}`,
852
- node: call,
853
- })
854
- return null
855
- }
856
- }
857
-
858
- /**
859
- * Extract inline step from workflow.do() call with a function argument
860
- */
861
- function extractInlineStep(
862
- call: ts.CallExpression,
863
- context: ExtractionContext
864
- ): InlineStepMeta | null {
865
- const args = call.arguments
866
- if (args.length < 2) return null
867
-
868
- try {
869
- const stepName = extractStringLiteral(args[0], context.checker)
870
- const optionsArg = args.length >= 3 ? args[args.length - 1] : undefined
871
- const options =
872
- optionsArg && ts.isObjectLiteralExpression(optionsArg)
873
- ? extractStepOptions(optionsArg, context)
874
- : undefined
875
-
876
- return {
877
- type: 'inline',
878
- stepName,
879
- options,
880
- }
881
- } catch (error) {
882
- context.errors.push({
883
- message: `Failed to extract inline step: ${error instanceof Error ? error.message : String(error)}`,
884
- node: call,
885
- })
886
- return null
887
- }
888
- }
889
-
890
- /**
891
- * Check if the second argument of a workflow.do() call is a function
892
- */
893
- function isInlineDoCall(call: ts.CallExpression): boolean {
894
- const secondArg = call.arguments[1]
895
- return (
896
- !!secondArg &&
897
- (ts.isArrowFunction(secondArg) || ts.isFunctionExpression(secondArg))
898
- )
899
- }
900
-
901
- /**
902
- * Extract step options from options object
903
- */
904
- function extractStepOptions(
905
- optionsNode: ts.ObjectLiteralExpression,
906
- context: ExtractionContext
907
- ): RpcStepMeta['options'] {
908
- const options: RpcStepMeta['options'] = {}
909
-
910
- for (const prop of optionsNode.properties) {
911
- if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) {
912
- const propName = prop.name.text
913
-
914
- if (propName === 'retries') {
915
- const retries = extractNumberLiteral(prop.initializer)
916
- if (retries !== null) {
917
- options.retries = retries
918
- }
919
- } else if (propName === 'retryDelay') {
920
- try {
921
- if (ts.isStringLiteral(prop.initializer)) {
922
- options.retryDelay = prop.initializer.text
923
- } else {
924
- const delay = extractNumberLiteral(prop.initializer)
925
- if (delay !== null) {
926
- options.retryDelay = delay
927
- }
928
- }
929
- } catch {
930
- // Ignore extraction errors for retryDelay
931
- }
932
- } else if (propName === 'onError') {
933
- if (ts.isStringLiteral(prop.initializer)) {
934
- options.onError = prop.initializer.text
935
- } else {
936
- context.errors.push({
937
- message:
938
- 'onError must be a literal RPC name so it can be wired and drawn in the graph.',
939
- node: prop.initializer,
940
- })
941
- }
942
- } else if (propName === 'description') {
943
- try {
944
- options.description = extractStringLiteral(
945
- prop.initializer,
946
- context.checker
947
- )
948
- } catch {
949
- // Ignore extraction errors for description
950
- }
951
- }
952
- }
953
- }
954
-
955
- return Object.keys(options).length > 0 ? options : undefined
956
- }
957
-
958
- /**
959
- * Extract sleep step from workflow.sleep() call
960
- */
961
- function extractSleepStep(
962
- call: ts.CallExpression,
963
- context: ExtractionContext
964
- ): WorkflowStepMeta | null {
965
- const args = call.arguments
966
-
967
- if (args.length < 2) {
968
- return null
969
- }
970
-
971
- try {
972
- const stepName = extractStringLiteral(args[0], context.checker)
973
- let duration: string | number
974
-
975
- const numValue = extractNumberLiteral(args[1])
976
- if (numValue !== null) {
977
- duration = numValue
978
- } else {
979
- try {
980
- duration = extractStringLiteral(args[1], context.checker)
981
- } catch {
982
- // A duration computed at runtime (a loop variable, a field off the
983
- // input) is legal: the closure evaluates it. Record the source text so
984
- // the graph can show what it waits on, rather than failing the workflow.
985
- return {
986
- type: 'sleep',
987
- stepName,
988
- duration: '',
989
- expression: args[1].getText(),
990
- }
991
- }
992
- }
993
-
994
- return {
995
- type: 'sleep',
996
- stepName,
997
- duration,
998
- }
999
- } catch (error) {
1000
- context.errors.push({
1001
- message: `Failed to extract sleep step: ${error instanceof Error ? error.message : String(error)}`,
1002
- node: call,
1003
- })
1004
- return null
1005
- }
1006
- }
1007
-
1008
- /**
1009
- * Extract suspend step from workflow.suspend() call
1010
- */
1011
- function extractSuspendStep(
1012
- call: ts.CallExpression,
1013
- context: ExtractionContext
1014
- ): SuspendStepMeta | null {
1015
- const args = call.arguments
1016
- if (args.length < 1) return null
1017
-
1018
- try {
1019
- const reason = extractStringLiteral(args[0], context.checker)
1020
- return {
1021
- type: 'suspend',
1022
- reason,
1023
- }
1024
- } catch (error) {
1025
- context.errors.push({
1026
- message: `Failed to extract suspend step: ${error instanceof Error ? error.message : String(error)}`,
1027
- node: call,
1028
- })
1029
- return null
1030
- }
1031
- }
1032
-
1033
- /**
1034
- * Extract approval step from workflow.approval() call
1035
- */
1036
- function extractApprovalStep(
1037
- call: ts.CallExpression,
1038
- context: ExtractionContext,
1039
- outputVar?: string
1040
- ): ApprovalStepMeta | null {
1041
- const args = call.arguments
1042
- if (args.length < 1) return null
1043
-
1044
- try {
1045
- const reason = extractStringLiteral(args[0], context.checker)
1046
- const step: ApprovalStepMeta = {
1047
- type: 'approval',
1048
- reason,
1049
- }
1050
- if (outputVar) {
1051
- step.outputVar = outputVar
1052
- }
1053
- // The `schema` option is a runtime value validated inside the workflow body,
1054
- // so it is deliberately not serialized here — only the literal options the
1055
- // graph and planned-step ladder need in order to describe the gate.
1056
- const options = args[1]
1057
- if (options && ts.isObjectLiteralExpression(options)) {
1058
- for (const prop of options.properties) {
1059
- if (!ts.isPropertyAssignment(prop)) continue
1060
- const name = prop.name.getText()
1061
- if (
1062
- name === 'expiry' &&
1063
- (ts.isStringLiteral(prop.initializer) ||
1064
- ts.isNumericLiteral(prop.initializer))
1065
- ) {
1066
- step.expiry = ts.isNumericLiteral(prop.initializer)
1067
- ? Number(prop.initializer.text)
1068
- : prop.initializer.text
1069
- }
1070
- if (name === 'approvers' && ts.isStringLiteral(prop.initializer)) {
1071
- step.approvers = prop.initializer
1072
- .text as ApprovalStepMeta['approvers']
1073
- }
1074
- if (name === 'approverScope' && ts.isStringLiteral(prop.initializer)) {
1075
- step.approverScope = prop.initializer.text
1076
- }
1077
- }
1078
- }
1079
- return step
1080
- } catch (error) {
1081
- context.errors.push({
1082
- message: `Failed to extract approval step: ${error instanceof Error ? error.message : String(error)}`,
1083
- node: call,
1084
- })
1085
- return null
1086
- }
1087
- }
1088
-
1089
- /**
1090
- * Extract cancel step from throw WorkflowCancelledException statement
1091
- */
1092
- function extractThrowCancel(
1093
- statement: ts.ThrowStatement,
1094
- context: ExtractionContext
1095
- ): CancelStepMeta | null {
1096
- if (!isThrowCancelException(statement)) {
1097
- return null
1098
- }
1099
-
1100
- const reason = extractCancelReason(statement, context.checker)
1101
- return {
1102
- type: 'cancel',
1103
- reason,
1104
- }
1105
- }
1106
-
1107
- /**
1108
- * Parse a condition expression into a Condition structure
1109
- */
1110
- function parseCondition(expr: ts.Expression): Condition {
1111
- // Handle binary expressions (&&, ||)
1112
- if (ts.isBinaryExpression(expr)) {
1113
- const operator = expr.operatorToken.kind
1114
-
1115
- // AND operator (&&)
1116
- if (operator === ts.SyntaxKind.AmpersandAmpersandToken) {
1117
- return {
1118
- type: 'and',
1119
- conditions: [parseCondition(expr.left), parseCondition(expr.right)],
1120
- }
1121
- }
1122
-
1123
- // OR operator (||)
1124
- if (operator === ts.SyntaxKind.BarBarToken) {
1125
- return {
1126
- type: 'or',
1127
- conditions: [parseCondition(expr.left), parseCondition(expr.right)],
1128
- }
1129
- }
1130
- }
1131
-
1132
- // Handle parenthesized expressions - unwrap and parse inner
1133
- if (ts.isParenthesizedExpression(expr)) {
1134
- return parseCondition(expr.expression)
1135
- }
1136
-
1137
- // Simple condition (comparison, function call, variable, etc.)
1138
- return {
1139
- type: 'simple',
1140
- expression: getSourceText(expr),
1141
- }
1142
- }
1143
-
1144
- /**
1145
- * Extract branch step from if statement (supports if/else-if/else chains)
1146
- */
1147
- function extractBranch(
1148
- statement: ts.IfStatement,
1149
- context: ExtractionContext
1150
- ): BranchStepMeta | null {
1151
- const branches: BranchStepMeta['branches'] = []
1152
- let elseSteps: BranchStepMeta['elseSteps']
1153
-
1154
- // Walk the if/else-if chain
1155
- let current: ts.IfStatement | undefined = statement
1156
- while (current) {
1157
- const condition = parseCondition(current.expression)
1158
- const steps = ts.isBlock(current.thenStatement)
1159
- ? extractSteps(current.thenStatement, context, true)
1160
- : extractStepsFromStatement(current.thenStatement, context)
1161
-
1162
- branches.push({ condition, steps })
1163
-
1164
- // Check for else-if or else
1165
- if (current.elseStatement) {
1166
- if (ts.isIfStatement(current.elseStatement)) {
1167
- // else-if: continue the chain
1168
- current = current.elseStatement
1169
- } else {
1170
- // else: extract the final else block and stop
1171
- elseSteps = ts.isBlock(current.elseStatement)
1172
- ? extractSteps(current.elseStatement, context, true)
1173
- : extractStepsFromStatement(current.elseStatement, context)
1174
- current = undefined
1175
- }
1176
- } else {
1177
- // No else clause
1178
- current = undefined
1179
- }
1180
- }
1181
-
1182
- return {
1183
- type: 'branch',
1184
- branches,
1185
- elseSteps,
1186
- }
1187
- }
1188
-
1189
- /**
1190
- * Extract steps from a single statement (non-block)
1191
- */
1192
- function extractStepsFromStatement(
1193
- statement: ts.Statement,
1194
- context: ExtractionContext
1195
- ): WorkflowStepMeta[] {
1196
- // Increment depth for single-statement blocks (if without braces)
1197
- context.depth++
1198
- const step = extractStep(statement, context)
1199
- context.depth--
1200
- return step ? [step] : []
1201
- }
1202
-
1203
- /**
1204
- * Extract switch statement
1205
- */
1206
- function extractSwitch(
1207
- statement: ts.SwitchStatement,
1208
- context: ExtractionContext
1209
- ): SwitchStepMeta | null {
1210
- const expression = getSourceText(statement.expression)
1211
- const cases: SwitchCaseMeta[] = []
1212
- let defaultSteps: WorkflowStepMeta[] | undefined
1213
-
1214
- for (const clause of statement.caseBlock.clauses) {
1215
- if (ts.isCaseClause(clause)) {
1216
- const caseValue = extractCaseValue(clause.expression)
1217
- const steps = extractCaseSteps(clause.statements, context)
1218
-
1219
- cases.push({
1220
- value: caseValue.value,
1221
- expression: caseValue.expression,
1222
- steps,
1223
- })
1224
- } else if (ts.isDefaultClause(clause)) {
1225
- defaultSteps = extractCaseSteps(clause.statements, context)
1226
- }
1227
- }
1228
-
1229
- return {
1230
- type: 'switch',
1231
- expression,
1232
- cases,
1233
- defaultSteps,
1234
- }
1235
- }
1236
-
1237
- /**
1238
- * Extract case value from expression
1239
- */
1240
- function extractCaseValue(expr: ts.Expression): {
1241
- value?: string | number | boolean | null
1242
- expression?: string
1243
- } {
1244
- if (ts.isStringLiteral(expr)) {
1245
- return { value: expr.text }
1246
- }
1247
- if (ts.isNumericLiteral(expr)) {
1248
- return { value: Number(expr.text) }
1249
- }
1250
- if (expr.kind === ts.SyntaxKind.TrueKeyword) {
1251
- return { value: true }
1252
- }
1253
- if (expr.kind === ts.SyntaxKind.FalseKeyword) {
1254
- return { value: false }
1255
- }
1256
- if (expr.kind === ts.SyntaxKind.NullKeyword) {
1257
- return { value: null }
1258
- }
1259
-
1260
- return { expression: getSourceText(expr) }
1261
- }
1262
-
1263
- /**
1264
- * Extract steps from case statements, stopping at break
1265
- */
1266
- function extractCaseSteps(
1267
- statements: ts.NodeArray<ts.Statement>,
1268
- context: ExtractionContext
1269
- ): WorkflowStepMeta[] {
1270
- const steps: WorkflowStepMeta[] = []
1271
-
1272
- // Increment depth for case blocks
1273
- context.depth++
1274
-
1275
- for (const statement of statements) {
1276
- if (ts.isBreakStatement(statement)) {
1277
- break
1278
- }
1279
-
1280
- const step = extractStep(statement, context)
1281
- if (step) {
1282
- steps.push(step)
1283
- }
1284
- }
1285
-
1286
- // Restore depth
1287
- context.depth--
1288
-
1289
- return steps
1290
- }
1291
-
1292
- /**
1293
- * Extract array filter operation
1294
- */
1295
- function extractArrayFilter(
1296
- call: ts.CallExpression,
1297
- context: ExtractionContext,
1298
- outputVar?: string
1299
- ): FilterStepMeta | null {
1300
- if (!ts.isPropertyAccessExpression(call.expression)) {
1301
- return null
1302
- }
1303
-
1304
- const sourceExpr = call.expression.expression
1305
- const sourceVar = extractSourcePath(sourceExpr)
1306
-
1307
- if (!sourceVar) {
1308
- return null
1309
- }
1310
-
1311
- const filterFn = call.arguments[0]
1312
- if (!filterFn || !ts.isArrowFunction(filterFn)) {
1313
- return null
1314
- }
1315
-
1316
- const itemParam = filterFn.parameters[0]
1317
- if (!itemParam || !ts.isIdentifier(itemParam.name)) {
1318
- return null
1319
- }
1320
-
1321
- const itemVar = itemParam.name.text
1322
-
1323
- let condition: Condition
1324
- if (ts.isBlock(filterFn.body)) {
1325
- return null
1326
- } else {
1327
- condition = parseCondition(filterFn.body)
1328
- }
1329
-
1330
- return {
1331
- type: 'filter',
1332
- sourceVar,
1333
- itemVar,
1334
- condition,
1335
- outputVar,
1336
- }
1337
- }
1338
-
1339
- /**
1340
- * Extract array predicate operation (some/every)
1341
- */
1342
- function extractArrayPredicate(
1343
- call: ts.CallExpression,
1344
- context: ExtractionContext,
1345
- outputVar?: string
1346
- ): ArrayPredicateStepMeta | null {
1347
- if (!ts.isPropertyAccessExpression(call.expression)) {
1348
- return null
1349
- }
1350
-
1351
- const mode = call.expression.name.text as 'some' | 'every'
1352
- const sourceExpr = call.expression.expression
1353
- const sourceVar = extractSourcePath(sourceExpr)
1354
-
1355
- if (!sourceVar) {
1356
- return null
1357
- }
1358
-
1359
- const predicateFn = call.arguments[0]
1360
- if (!predicateFn || !ts.isArrowFunction(predicateFn)) {
1361
- return null
1362
- }
1363
-
1364
- const itemParam = predicateFn.parameters[0]
1365
- if (!itemParam || !ts.isIdentifier(itemParam.name)) {
1366
- return null
1367
- }
1368
-
1369
- const itemVar = itemParam.name.text
1370
-
1371
- let condition: Condition
1372
- if (ts.isBlock(predicateFn.body)) {
1373
- return null
1374
- } else {
1375
- condition = parseCondition(predicateFn.body)
1376
- }
1377
-
1378
- return {
1379
- type: 'arrayPredicate',
1380
- mode,
1381
- sourceVar,
1382
- itemVar,
1383
- condition,
1384
- outputVar,
1385
- }
1386
- }
1387
-
1388
- /**
1389
- * Extract an ordered list of workflow.do steps from a per-iteration body.
1390
- *
1391
- * Unlike if/switch blocks, a fanout body is a real per-iteration scope, so
1392
- * `const x = await workflow.do(...)` is meaningful here: the binding is
1393
- * registered on the child context so later steps in the same iteration can
1394
- * reference it.
1395
- */
1396
- function extractFanoutBodyStep(
1397
- stmt: ts.Statement,
1398
- childContext: ExtractionContext,
1399
- body: Array<RpcStepMeta | SleepStepMeta | SuspendStepMeta | ScenarioStepMeta>
1400
- ): void {
1401
- if (ts.isVariableStatement(stmt)) {
1402
- const declList = stmt.declarationList
1403
- if (declList.declarations.length !== 1) {
1404
- return
1405
- }
1406
- const decl = declList.declarations[0]
1407
- const init = decl.initializer
1408
- if (
1409
- !init ||
1410
- !ts.isAwaitExpression(init) ||
1411
- !ts.isCallExpression(init.expression)
1412
- ) {
1413
- return
1414
- }
1415
- const call = init.expression
1416
- if (
1417
- !isWorkflowDoCall(call, childContext.checker) &&
1418
- !isScenarioStepCall(call)
1419
- ) {
1420
- return
1421
- }
1422
- const varName = ts.isIdentifier(decl.name) ? decl.name.text : undefined
1423
- const step = isScenarioStepCall(call)
1424
- ? extractScenarioStep(call, childContext, varName)
1425
- : extractRpcStep(call, childContext, varName)
1426
- if (!step) {
1427
- return
1428
- }
1429
- if (varName) {
1430
- const type = childContext.checker.getTypeAtLocation(decl)
1431
- childContext.outputVars.set(varName, { type, node: decl })
1432
- if (isArrayType(type, childContext.checker)) {
1433
- childContext.arrayVars.add(varName)
1434
- }
1435
- }
1436
- body.push(step)
1437
- return
1438
- }
1439
-
1440
- if (ts.isExpressionStatement(stmt) || ts.isReturnStatement(stmt)) {
1441
- const expr = ts.isReturnStatement(stmt) ? stmt.expression : stmt.expression
1442
- if (!expr) {
1443
- return
1444
- }
1445
- const call = ts.isAwaitExpression(expr)
1446
- ? ts.isCallExpression(expr.expression)
1447
- ? expr.expression
1448
- : null
1449
- : ts.isCallExpression(expr)
1450
- ? expr
1451
- : null
1452
- if (!call) {
1453
- return
1454
- }
1455
- if (isWorkflowSleepCall(call, childContext.checker)) {
1456
- const sleepStep = extractSleepStep(call, childContext)
1457
- if (sleepStep && sleepStep.type === 'sleep') {
1458
- body.push(sleepStep)
1459
- }
1460
- return
1461
- }
1462
- if (isWorkflowSuspendCall(call, childContext.checker)) {
1463
- const suspendStep = extractSuspendStep(call, childContext)
1464
- if (suspendStep && suspendStep.type === 'suspend') {
1465
- body.push(suspendStep)
1466
- }
1467
- return
1468
- }
1469
- if (isScenarioStepCall(call)) {
1470
- const scenarioStep = extractScenarioStep(call, childContext)
1471
- if (scenarioStep) {
1472
- body.push(scenarioStep)
1473
- }
1474
- return
1475
- }
1476
- if (!isWorkflowDoCall(call, childContext.checker)) {
1477
- return
1478
- }
1479
- const step = extractRpcStep(call, childContext)
1480
- if (step) {
1481
- body.push(step)
1482
- }
1483
- }
1484
- }
1485
-
1486
- /**
1487
- * Extract parallel fanout from Promise.all(array.map(...))
1488
- */
1489
- function extractParallelFanout(
1490
- call: ts.CallExpression,
1491
- context: ExtractionContext
1492
- ): FanoutStepMeta | null {
1493
- const mapCall = call.arguments[0]
1494
- if (!ts.isCallExpression(mapCall)) {
1495
- return null
1496
- }
1497
-
1498
- if (!ts.isPropertyAccessExpression(mapCall.expression)) {
1499
- return null
1500
- }
1501
-
1502
- // Extract source array
1503
- const sourceExpr = mapCall.expression.expression
1504
- const sourceVar = extractSourcePath(sourceExpr)
1505
-
1506
- if (!sourceVar) {
1507
- return null
1508
- }
1509
-
1510
- // Extract map function
1511
- const mapFn = mapCall.arguments[0]
1512
- if (!ts.isArrowFunction(mapFn)) {
1513
- return null
1514
- }
1515
-
1516
- // Extract item variable
1517
- const itemParam = mapFn.parameters[0]
1518
- if (!itemParam || !ts.isIdentifier(itemParam.name)) {
1519
- return null
1520
- }
1521
-
1522
- const itemVar = itemParam.name.text
1523
-
1524
- // Create a temporary context for the child steps with the loop variable
1525
- const childContext: ExtractionContext = {
1526
- ...context,
1527
- outputVars: new Map(context.outputVars),
1528
- arrayVars: new Set(context.arrayVars),
1529
- loopVars: new Set([...context.loopVars, itemVar]),
1530
- }
1531
-
1532
- const body: FanoutStepMeta['body'] = []
1533
-
1534
- if (ts.isBlock(mapFn.body)) {
1535
- for (const stmt of mapFn.body.statements) {
1536
- extractFanoutBodyStep(stmt, childContext, body)
1537
- }
1538
- } else {
1539
- // Concise body: (item) => workflow.do(...) / async (item) => await workflow.do(...)
1540
- const expr = mapFn.body
1541
- const doCall = ts.isAwaitExpression(expr)
1542
- ? ts.isCallExpression(expr.expression)
1543
- ? expr.expression
1544
- : null
1545
- : ts.isCallExpression(expr)
1546
- ? expr
1547
- : null
1548
-
1549
- if (doCall && isScenarioStepCall(doCall)) {
1550
- const step = extractScenarioStep(doCall, childContext)
1551
- if (step) {
1552
- body.push(step)
1553
- }
1554
- } else if (doCall && isWorkflowDoCall(doCall, context.checker)) {
1555
- const step = extractRpcStep(doCall, childContext)
1556
- if (step) {
1557
- body.push(step)
1558
- }
1559
- }
1560
- }
1561
-
1562
- if (body.length === 0) {
1563
- return null
1564
- }
1565
-
1566
- return {
1567
- type: 'fanout',
1568
- sourceVar,
1569
- itemVar,
1570
- mode: 'parallel',
1571
- body,
1572
- }
1573
- }
1574
-
1575
- /**
1576
- * Extract parallel group from Promise.all([...])
1577
- */
1578
- function extractParallelGroup(
1579
- call: ts.CallExpression,
1580
- context: ExtractionContext
1581
- ): ParallelGroupStepMeta | null {
1582
- const arrayArg = call.arguments[0]
1583
- if (!ts.isArrayLiteralExpression(arrayArg)) {
1584
- return null
1585
- }
1586
-
1587
- const children: Array<RpcStepMeta | ScenarioStepMeta> = []
1588
-
1589
- for (const elem of arrayArg.elements) {
1590
- if (!ts.isCallExpression(elem)) {
1591
- continue
1592
- }
1593
- if (isScenarioStepCall(elem)) {
1594
- const step = extractScenarioStep(elem, context)
1595
- if (step) {
1596
- children.push(step)
1597
- }
1598
- } else if (isWorkflowDoCall(elem, context.checker)) {
1599
- const step = extractRpcStep(elem, context)
1600
- if (step) {
1601
- children.push(step)
1602
- }
1603
- }
1604
- }
1605
-
1606
- if (children.length === 0) {
1607
- return null
1608
- }
1609
-
1610
- return {
1611
- type: 'parallel',
1612
- children,
1613
- }
1614
- }
1615
-
1616
- /**
1617
- * Extract sequential fanout from for-of loop
1618
- */
1619
- /**
1620
- * Does this subtree call the workflow wire at all?
1621
- *
1622
- * Used to tell "the DSL can't model this loop, and a step would be silently
1623
- * lost" from "the DSL can't model this loop, and there is nothing in it to
1624
- * lose". Only the first is worth failing a build over.
1625
- */
1626
- function containsWorkflowCall(node: ts.Node, checker: ts.TypeChecker): boolean {
1627
- let found = false
1628
- const visit = (child: ts.Node): void => {
1629
- if (found) return
1630
- if (ts.isCallExpression(child)) {
1631
- if (
1632
- isWorkflowDoCall(child, checker) ||
1633
- isScenarioStepCall(child) ||
1634
- isWorkflowSleepCall(child, checker) ||
1635
- isWorkflowSuspendCall(child, checker) ||
1636
- isWorkflowApprovalCall(child, checker) ||
1637
- isWorkflowExpectEventuallyCall(child)
1638
- ) {
1639
- found = true
1640
- return
1641
- }
1642
- }
1643
- ts.forEachChild(child, visit)
1644
- }
1645
- visit(node)
1646
- return found
1647
- }
1648
-
1649
- function extractSequentialFanout(
1650
- statement: ts.ForOfStatement,
1651
- context: ExtractionContext,
1652
- reason?: { message?: string }
1653
- ): FanoutStepMeta | null {
1654
- if (!isSequentialFanout(statement)) {
1655
- return null
1656
- }
1657
-
1658
- const vars = extractForOfVariable(statement)
1659
- if (!vars) {
1660
- return null
1661
- }
1662
-
1663
- const { itemVar, sourceVar } = vars
1664
-
1665
- // Extract child steps and optional sleep from the loop body. A brace-less
1666
- // body (`for (const x of xs) await workflow.do(...)`) is a single statement
1667
- // rather than a block, and must still be extracted.
1668
- const bodyStatements = ts.isBlock(statement.statement)
1669
- ? statement.statement.statements
1670
- : [statement.statement]
1671
-
1672
- const body: FanoutStepMeta['body'] = []
1673
- let timeBetween: string | undefined = undefined
1674
-
1675
- // Create a child context with the loop variable added
1676
- const childContext: ExtractionContext = {
1677
- ...context,
1678
- outputVars: new Map(context.outputVars),
1679
- arrayVars: new Set(context.arrayVars),
1680
- loopVars: new Set([...context.loopVars, itemVar]),
1681
- }
1682
-
1683
- for (const stmt of bodyStatements) {
1684
- extractFanoutBodyStep(stmt, childContext, body)
1685
-
1686
- // Look for workflow.sleep in ExpressionStatement
1687
- if (ts.isExpressionStatement(stmt)) {
1688
- const expr = stmt.expression
1689
-
1690
- if (ts.isAwaitExpression(expr) && ts.isCallExpression(expr.expression)) {
1691
- const call = expr.expression
1692
-
1693
- if (isWorkflowSleepCall(call, context.checker)) {
1694
- // Extract duration for timeBetween
1695
- const args = call.arguments
1696
- if (args.length >= 2) {
1697
- try {
1698
- const numValue = extractNumberLiteral(args[1])
1699
- if (numValue !== null) {
1700
- timeBetween = `${numValue}ms`
1701
- } else {
1702
- timeBetween = extractStringLiteral(args[1], context.checker)
1703
- }
1704
- } catch {
1705
- // Ignore extraction errors
1706
- }
1707
- }
1708
- }
1709
- }
1710
- }
1711
-
1712
- // Look for if statement with sleep
1713
- if (ts.isIfStatement(stmt)) {
1714
- if (ts.isBlock(stmt.thenStatement)) {
1715
- for (const thenStmt of stmt.thenStatement.statements) {
1716
- if (ts.isExpressionStatement(thenStmt)) {
1717
- const expr = thenStmt.expression
1718
-
1719
- if (
1720
- ts.isAwaitExpression(expr) &&
1721
- ts.isCallExpression(expr.expression)
1722
- ) {
1723
- const call = expr.expression
1724
-
1725
- if (isWorkflowSleepCall(call, context.checker)) {
1726
- const args = call.arguments
1727
- if (args.length >= 2) {
1728
- try {
1729
- const numValue = extractNumberLiteral(args[1])
1730
- if (numValue !== null) {
1731
- timeBetween = `${numValue}ms`
1732
- } else {
1733
- timeBetween = extractStringLiteral(
1734
- args[1],
1735
- context.checker
1736
- )
1737
- }
1738
- } catch {
1739
- // Ignore extraction errors
1740
- }
1741
- }
1742
- }
1743
- }
1744
- }
1745
- }
1746
- }
1747
- }
1748
- }
1749
-
1750
- if (body.length === 0) {
1751
- // The iterable was fine — the body yielded nothing the DSL can model. The
1752
- // usual cause is a `workflow.do` nested inside an `if`/`switch`, because
1753
- // `FanoutStepMeta['body']` is a flat list of steps with no branch member:
1754
- // the DSL has no way to say "conditionally run this step per item".
1755
- const branching = bodyStatements.some(
1756
- (stmt) => ts.isIfStatement(stmt) || ts.isSwitchStatement(stmt)
1757
- )
1758
- if (reason) {
1759
- reason.message = branching
1760
- ? `its body only calls 'workflow.do' inside an if/switch, and a DSL fanout body is a flat list of steps with no branch member — the DSL cannot express a per-item condition. Use pikkuWorkflowComplexFunc, or lift the condition out of the loop.`
1761
- : `its body contains no 'workflow.do' call the DSL can model. Use pikkuWorkflowComplexFunc for a loop that does other work.`
1762
- }
1763
- return null
1764
- }
1765
-
1766
- return {
1767
- type: 'fanout',
1768
- sourceVar,
1769
- itemVar,
1770
- mode: 'sequential',
1771
- body,
1772
- timeBetween,
1773
- }
1774
- }
1775
-
1776
- /**
1777
- * Extract a single output binding from an expression
1778
- */
1779
- function extractOutputBinding(
1780
- expr: ts.Expression,
1781
- context: ExtractionContext
1782
- ): OutputBinding | null {
1783
- // Check for property access (e.g., org.id, payment.status)
1784
- if (ts.isPropertyAccessExpression(expr)) {
1785
- const objName = ts.isIdentifier(expr.expression)
1786
- ? expr.expression.text
1787
- : null
1788
- const propPath = expr.name.text
1789
-
1790
- if (objName && context.outputVars.has(objName)) {
1791
- return { from: 'outputVar', name: objName, path: propPath }
1792
- }
1793
- if (objName && context.contextVars.has(objName)) {
1794
- return { from: 'stateVar', name: objName, path: propPath }
1795
- }
1796
- if (objName === context.inputParamName) {
1797
- return { from: 'input', path: propPath }
1798
- }
1799
- }
1800
-
1801
- // Check for identifier (simple variable reference)
1802
- if (ts.isIdentifier(expr)) {
1803
- const varName = expr.text
1804
- if (context.outputVars.has(varName)) {
1805
- return { from: 'outputVar', name: varName }
1806
- }
1807
- if (context.contextVars.has(varName)) {
1808
- return { from: 'stateVar', name: varName }
1809
- }
1810
- if (varName === context.inputParamName) {
1811
- return { from: 'input', path: varName }
1812
- }
1813
- }
1814
-
1815
- // Check for literals
1816
- if (ts.isStringLiteral(expr)) {
1817
- return { from: 'literal', value: expr.text }
1818
- }
1819
- if (ts.isNumericLiteral(expr)) {
1820
- return { from: 'literal', value: Number(expr.text) }
1821
- }
1822
- if (expr.kind === ts.SyntaxKind.TrueKeyword) {
1823
- return { from: 'literal', value: true }
1824
- }
1825
- if (expr.kind === ts.SyntaxKind.FalseKeyword) {
1826
- return { from: 'literal', value: false }
1827
- }
1828
- if (expr.kind === ts.SyntaxKind.NullKeyword) {
1829
- return { from: 'literal', value: null }
1830
- }
1831
-
1832
- // For any other expression (comparisons, method calls, etc.), capture as expression
1833
- return { from: 'expression', expression: getSourceText(expr) }
1834
- }
1835
-
1836
- /**
1837
- * Extract return step
1838
- */
1839
- function extractReturn(
1840
- statement: ts.ReturnStatement,
1841
- context: ExtractionContext
1842
- ): WorkflowStepMeta | null {
1843
- if (!statement.expression) {
1844
- return null
1845
- }
1846
-
1847
- if (
1848
- ts.isAwaitExpression(statement.expression) &&
1849
- ts.isCallExpression(statement.expression.expression)
1850
- ) {
1851
- const call = statement.expression.expression
1852
- if (isWorkflowDoCall(call, context.checker)) {
1853
- return isInlineDoCall(call)
1854
- ? extractInlineStep(call, context)
1855
- : extractRpcStep(call, context)
1856
- }
1857
- if (isWorkflowSleepCall(call, context.checker)) {
1858
- return extractSleepStep(call, context)
1859
- }
1860
- }
1861
-
1862
- if (ts.isCallExpression(statement.expression)) {
1863
- const call = statement.expression
1864
- if (isWorkflowDoCall(call, context.checker)) {
1865
- return isInlineDoCall(call)
1866
- ? extractInlineStep(call, context)
1867
- : extractRpcStep(call, context)
1868
- }
1869
- if (isWorkflowSleepCall(call, context.checker)) {
1870
- return extractSleepStep(call, context)
1871
- }
1872
- }
1873
-
1874
- // `return r` returns every field of r — record it as a spread of one.
1875
- if (ts.isIdentifier(statement.expression)) {
1876
- const varName = statement.expression.text
1877
- if (context.outputVars.has(varName) || context.contextVars.has(varName)) {
1878
- return { type: 'return', outputs: {}, spread: [varName] }
1879
- }
1880
- return null
1881
- }
1882
-
1883
- if (!ts.isObjectLiteralExpression(statement.expression)) {
1884
- return null
1885
- }
1886
-
1887
- const outputs: Record<string, OutputBinding> = {}
1888
- const spread: string[] = []
1889
-
1890
- for (const prop of statement.expression.properties) {
1891
- if (
1892
- ts.isPropertyAssignment(prop) ||
1893
- ts.isShorthandPropertyAssignment(prop)
1894
- ) {
1895
- const propName = ts.isIdentifier(prop.name) ? prop.name.text : null
1896
- if (!propName) {
1897
- continue
1898
- }
1899
-
1900
- let binding: OutputBinding | null = null
1901
-
1902
- if (ts.isShorthandPropertyAssignment(prop)) {
1903
- // { orgId } - must be an output variable, context variable, or input
1904
- const varName = prop.name.text
1905
- if (context.outputVars.has(varName)) {
1906
- binding = { from: 'outputVar', name: varName }
1907
- } else if (context.contextVars.has(varName)) {
1908
- binding = { from: 'stateVar', name: varName }
1909
- } else {
1910
- binding = { from: 'input', path: varName }
1911
- }
1912
- } else if (ts.isPropertyAssignment(prop)) {
1913
- binding = extractOutputBinding(prop.initializer, context)
1914
- }
1915
-
1916
- if (binding) {
1917
- outputs[propName] = binding
1918
- }
1919
- } else if (
1920
- ts.isSpreadAssignment(prop) &&
1921
- ts.isIdentifier(prop.expression)
1922
- ) {
1923
- spread.push(prop.expression.text)
1924
- }
1925
- }
1926
-
1927
- if (Object.keys(outputs).length === 0 && spread.length === 0) {
1928
- return null
1929
- }
1930
-
1931
- return {
1932
- type: 'return',
1933
- outputs,
1934
- ...(spread.length > 0 ? { spread } : {}),
1935
- }
1936
- }
1937
-
1938
- /**
1939
- * Extract input sources from an argument node
1940
- */
1941
- function extractInputSources(
1942
- node: ts.Node,
1943
- context: ExtractionContext
1944
- ): Record<string, InputSource> | 'passthrough' | undefined {
1945
- // Handle when data is passed directly (e.g., workflow.do('step', 'rpc', data))
1946
- if (ts.isIdentifier(node)) {
1947
- if (node.text === context.inputParamName) {
1948
- // The entire input data is being passed through
1949
- return 'passthrough'
1950
- }
1951
- // Check if it's an output variable being passed directly
1952
- if (context.outputVars.has(node.text)) {
1953
- return 'passthrough'
1954
- }
1955
- }
1956
-
1957
- if (!ts.isObjectLiteralExpression(node)) {
1958
- return undefined
1959
- }
1960
-
1961
- const inputs: Record<string, InputSource> = {}
1962
-
1963
- for (const prop of node.properties) {
1964
- if (
1965
- ts.isPropertyAssignment(prop) ||
1966
- ts.isShorthandPropertyAssignment(prop)
1967
- ) {
1968
- const propName = ts.isIdentifier(prop.name) ? prop.name.text : null
1969
- if (!propName) {
1970
- continue
1971
- }
1972
-
1973
- let source: InputSource | null = null
1974
-
1975
- if (ts.isShorthandPropertyAssignment(prop)) {
1976
- // { email } - could be from loop var, output var, or input
1977
- const varName = prop.name.text
1978
- if (context.loopVars.has(varName)) {
1979
- source = { from: 'item', path: varName }
1980
- } else if (context.outputVars.has(varName)) {
1981
- source = { from: 'outputVar', name: varName }
1982
- } else {
1983
- const constant = resolveConstantValue(prop.name, context.checker)
1984
- source =
1985
- constant !== undefined
1986
- ? { from: 'literal', value: constant }
1987
- : { from: 'input', path: varName }
1988
- }
1989
- } else if (ts.isPropertyAssignment(prop)) {
1990
- source = extractInputSource(prop.initializer, context)
1991
- }
1992
-
1993
- if (source) {
1994
- inputs[propName] = source
1995
- }
1996
- }
1997
-
1998
- if (ts.isSpreadAssignment(prop)) {
1999
- // Handle spread: { ...data }
2000
- if (ts.isIdentifier(prop.expression)) {
2001
- const varName = prop.expression.text
2002
- if (varName === context.inputParamName) {
2003
- // This is spreading the input data
2004
- // We can't fully model this in v1, so we'll skip it
2005
- continue
2006
- }
2007
- }
2008
- }
2009
- }
2010
-
2011
- return Object.keys(inputs).length > 0 ? inputs : undefined
2012
- }
2013
-
2014
- function inputSourceToInlineValue(source: InputSource): unknown {
2015
- switch (source.from) {
2016
- case 'literal':
2017
- return source.value
2018
- case 'input':
2019
- return { $ref: 'trigger', path: source.path }
2020
- case 'outputVar':
2021
- return { $ref: source.name, path: source.path }
2022
- case 'item':
2023
- return { $ref: '$item', path: source.path }
2024
- case 'template':
2025
- return {
2026
- $template: {
2027
- parts: source.parts,
2028
- expressions: source.expressions.map(inputSourceToInlineValue),
2029
- },
2030
- }
2031
- }
2032
- }
2033
-
2034
- /**
2035
- * Extract a single input source
2036
- */
2037
- function extractInputSource(
2038
- node: ts.Node,
2039
- context: ExtractionContext
2040
- ): InputSource | null {
2041
- // Property access: data.email, org.id
2042
- if (ts.isPropertyAccessExpression(node)) {
2043
- const objExpr = node.expression
2044
- const propName = node.name.text
2045
-
2046
- if (ts.isIdentifier(objExpr)) {
2047
- const objName = objExpr.text
2048
-
2049
- if (objName === context.inputParamName) {
2050
- return { from: 'input', path: propName }
2051
- }
2052
-
2053
- // A field of the fanout item: users.map((u) => do(..., { id: u.id }))
2054
- if (context.loopVars.has(objName)) {
2055
- return { from: 'item', path: propName }
2056
- }
2057
-
2058
- if (context.outputVars.has(objName)) {
2059
- return { from: 'outputVar', name: objName, path: propName }
2060
- }
2061
- }
2062
- }
2063
-
2064
- // Identifier: email, orgId
2065
- if (ts.isIdentifier(node)) {
2066
- const varName = node.text
2067
-
2068
- // Check if it's a loop variable (from fanout)
2069
- if (context.loopVars.has(varName)) {
2070
- return { from: 'item', path: varName }
2071
- }
2072
-
2073
- if (context.outputVars.has(varName)) {
2074
- return { from: 'outputVar', name: varName }
2075
- }
2076
-
2077
- const constant = resolveConstantValue(node, context.checker)
2078
- if (constant !== undefined) {
2079
- return { from: 'literal', value: constant }
2080
- }
2081
-
2082
- // Assume it's from input
2083
- return { from: 'input', path: varName }
2084
- }
2085
-
2086
- // Literal: "string", 123, true, false, null
2087
- if (
2088
- ts.isStringLiteral(node) ||
2089
- ts.isNumericLiteral(node) ||
2090
- node.kind === ts.SyntaxKind.TrueKeyword ||
2091
- node.kind === ts.SyntaxKind.FalseKeyword ||
2092
- node.kind === ts.SyntaxKind.NullKeyword
2093
- ) {
2094
- let value: unknown
2095
- if (ts.isStringLiteral(node)) {
2096
- value = node.text
2097
- } else if (ts.isNumericLiteral(node)) {
2098
- value = Number(node.text)
2099
- } else if (node.kind === ts.SyntaxKind.TrueKeyword) {
2100
- value = true
2101
- } else if (node.kind === ts.SyntaxKind.FalseKeyword) {
2102
- value = false
2103
- } else if (node.kind === ts.SyntaxKind.NullKeyword) {
2104
- value = null
2105
- }
2106
- return { from: 'literal', value }
2107
- }
2108
-
2109
- // Object literal
2110
- if (ts.isObjectLiteralExpression(node)) {
2111
- const obj: Record<string, unknown> = {}
2112
- for (const prop of node.properties) {
2113
- if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) {
2114
- const propName = prop.name.text
2115
- const propSource = extractInputSource(prop.initializer, context)
2116
- if (propSource) {
2117
- obj[propName] = inputSourceToInlineValue(propSource)
2118
- }
2119
- }
2120
- }
2121
- return { from: 'literal', value: obj }
2122
- }
2123
-
2124
- // Array literal
2125
- if (ts.isArrayLiteralExpression(node)) {
2126
- const arr: unknown[] = []
2127
- for (const elem of node.elements) {
2128
- const elemSource = extractInputSource(elem, context)
2129
- if (elemSource) {
2130
- arr.push(inputSourceToInlineValue(elemSource))
2131
- }
2132
- }
2133
- return { from: 'literal', value: arr }
2134
- }
2135
-
2136
- // No substitution template literal: `hello`
2137
- if (ts.isNoSubstitutionTemplateLiteral(node)) {
2138
- return { from: 'literal', value: node.text }
2139
- }
2140
-
2141
- // Template expression with substitutions: `hello ${name}`
2142
- if (ts.isTemplateExpression(node)) {
2143
- const parts: string[] = [node.head.text]
2144
- const expressions: InputSource[] = []
2145
-
2146
- for (const span of node.templateSpans) {
2147
- // Extract each expression
2148
- const exprSource = extractInputSource(span.expression, context)
2149
- if (exprSource) {
2150
- expressions.push(exprSource)
2151
- } else {
2152
- // Fallback: use source text as literal
2153
- expressions.push({
2154
- from: 'literal',
2155
- value: getSourceText(span.expression),
2156
- })
2157
- }
2158
- parts.push(span.literal.text)
2159
- }
2160
-
2161
- return { from: 'template', parts, expressions }
2162
- }
2163
-
2164
- return null
2165
- }
2166
-
2167
- /**
2168
- * Resolve an identifier that names a constant to the value it was declared with.
2169
- *
2170
- * Without this, a name the extractor does not recognise falls through to
2171
- * `{ from: 'input' }` and is serialized as `{ $ref: 'trigger', path: <name> }` —
2172
- * so a module-level `const RESOURCE_ID = 'x'` used as step input silently
2173
- * becomes a read of a trigger field that does not exist. Only `const`
2174
- * declarations with a fully literal initializer resolve; anything else (a name
2175
- * bound out of the trigger, a computed value) falls through unchanged.
2176
- */
2177
- function resolveConstantValue(
2178
- node: ts.Identifier,
2179
- checker: ts.TypeChecker
2180
- ): unknown | undefined {
2181
- const symbol = checker.getSymbolAtLocation(node)
2182
- const declaration = symbol?.valueDeclaration ?? symbol?.declarations?.[0]
2183
- if (!declaration || !ts.isVariableDeclaration(declaration)) {
2184
- return undefined
2185
- }
2186
- const declarationList = declaration.parent
2187
- if (
2188
- !ts.isVariableDeclarationList(declarationList) ||
2189
- !(declarationList.flags & ts.NodeFlags.Const)
2190
- ) {
2191
- return undefined
2192
- }
2193
- return declaration.initializer
2194
- ? extractLiteralValue(declaration.initializer)
2195
- : undefined
2196
- }
2197
-
2198
- /**
2199
- * Extract a literal value from an expression
2200
- */
2201
- function extractLiteralValue(expr: ts.Expression): unknown | undefined {
2202
- if (ts.isStringLiteral(expr)) {
2203
- return expr.text
2204
- }
2205
- if (ts.isNumericLiteral(expr)) {
2206
- return Number(expr.text)
2207
- }
2208
- if (expr.kind === ts.SyntaxKind.TrueKeyword) {
2209
- return true
2210
- }
2211
- if (expr.kind === ts.SyntaxKind.FalseKeyword) {
2212
- return false
2213
- }
2214
- if (expr.kind === ts.SyntaxKind.NullKeyword) {
2215
- return null
2216
- }
2217
- // Array literal
2218
- if (ts.isArrayLiteralExpression(expr)) {
2219
- const values: unknown[] = []
2220
- for (const el of expr.elements) {
2221
- const v = extractLiteralValue(el)
2222
- if (v === undefined) return undefined
2223
- values.push(v)
2224
- }
2225
- return values
2226
- }
2227
- // Object literal (simple keys with literal values)
2228
- if (ts.isObjectLiteralExpression(expr)) {
2229
- const obj: Record<string, unknown> = {}
2230
- for (const prop of expr.properties) {
2231
- if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) {
2232
- const v = extractLiteralValue(prop.initializer)
2233
- if (v === undefined) return undefined
2234
- obj[prop.name.text] = v
2235
- } else {
2236
- return undefined
2237
- }
2238
- }
2239
- return obj
2240
- }
2241
- return undefined
2242
- }
2243
-
2244
- /**
2245
- * Infer a simple type string from a TypeScript type
2246
- */
2247
- function inferSimpleType(type: ts.Type, checker: ts.TypeChecker): string {
2248
- const typeStr = checker.typeToString(type)
2249
- if (typeStr === 'string') return 'string'
2250
- if (typeStr === 'number') return 'number'
2251
- if (typeStr === 'boolean') return 'boolean'
2252
- if (typeStr.endsWith('[]') || typeStr.startsWith('Array<')) return 'array'
2253
- return 'object'
2254
- }