@pikku/inspector 0.12.75 → 0.12.82

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 (223) hide show
  1. package/CHANGELOG.md +357 -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-middleware.js +66 -42
  8. package/dist/add/add-wire-addon.js +7 -4
  9. package/dist/add/add-wire-remote-addon.js +1 -0
  10. package/dist/error-codes.d.ts +1 -0
  11. package/dist/error-codes.js +1 -0
  12. package/dist/index.d.ts +3 -1
  13. package/dist/index.js +3 -1
  14. package/dist/inspector.js +5 -0
  15. package/dist/surface/collect-surface.d.ts +58 -0
  16. package/dist/surface/collect-surface.js +613 -0
  17. package/dist/types.d.ts +36 -12
  18. package/dist/utils/filter-inspector-state.js +14 -0
  19. package/dist/utils/load-addon-functions-meta.js +24 -11
  20. package/dist/utils/post-process.js +15 -0
  21. package/dist/utils/read-module-specifiers.d.ts +8 -0
  22. package/dist/utils/read-module-specifiers.js +23 -0
  23. package/dist/utils/read-tsconfig-out-dir.d.ts +5 -0
  24. package/dist/utils/read-tsconfig-out-dir.js +12 -0
  25. package/dist/utils/resolve-deploy-target.d.ts +7 -0
  26. package/dist/utils/resolve-deploy-target.js +15 -7
  27. package/dist/utils/serialize-inspector-state.d.ts +6 -0
  28. package/dist/utils/serialize-inspector-state.js +10 -0
  29. package/dist/visit.js +4 -0
  30. package/package.json +16 -6
  31. package/run-tests.sh +21 -6
  32. package/src/add/add-addon-bans.ts +0 -85
  33. package/src/add/add-agent-scorer.test.ts +0 -197
  34. package/src/add/add-agent-scorer.ts +0 -83
  35. package/src/add/add-agent.test.ts +0 -87
  36. package/src/add/add-agent.ts +0 -638
  37. package/src/add/add-approval-description.ts +0 -76
  38. package/src/add/add-auth.test.ts +0 -791
  39. package/src/add/add-auth.ts +0 -424
  40. package/src/add/add-channel.ts +0 -778
  41. package/src/add/add-cli-renderers.test.ts +0 -74
  42. package/src/add/add-cli.ts +0 -1006
  43. package/src/add/add-credential.ts +0 -186
  44. package/src/add/add-feature.test.ts +0 -306
  45. package/src/add/add-feature.ts +0 -253
  46. package/src/add/add-file-extends-core-type.ts +0 -66
  47. package/src/add/add-file-with-config.ts +0 -45
  48. package/src/add/add-file-with-factory.test.ts +0 -134
  49. package/src/add/add-file-with-factory.ts +0 -288
  50. package/src/add/add-functions.test.ts +0 -743
  51. package/src/add/add-functions.ts +0 -1548
  52. package/src/add/add-gateway.ts +0 -143
  53. package/src/add/add-http-route-ref.test.ts +0 -149
  54. package/src/add/add-http-route.ts +0 -530
  55. package/src/add/add-http-routes.ts +0 -471
  56. package/src/add/add-keyed-wiring.ts +0 -180
  57. package/src/add/add-mcp-prompt.ts +0 -157
  58. package/src/add/add-mcp-resource.ts +0 -175
  59. package/src/add/add-middleware.test.ts +0 -196
  60. package/src/add/add-middleware.ts +0 -879
  61. package/src/add/add-permission-auth.test.ts +0 -59
  62. package/src/add/add-permission.ts +0 -311
  63. package/src/add/add-personas.test.ts +0 -406
  64. package/src/add/add-personas.ts +0 -287
  65. package/src/add/add-queue-worker.ts +0 -117
  66. package/src/add/add-rpc-invocations.test.ts +0 -287
  67. package/src/add/add-rpc-invocations.ts +0 -221
  68. package/src/add/add-scenario-step.test.ts +0 -509
  69. package/src/add/add-schedule.ts +0 -120
  70. package/src/add/add-scope.test.ts +0 -419
  71. package/src/add/add-scope.ts +0 -221
  72. package/src/add/add-secret.ts +0 -10
  73. package/src/add/add-subject-scenario-steps.test.ts +0 -161
  74. package/src/add/add-system-role.test.ts +0 -297
  75. package/src/add/add-system-role.ts +0 -162
  76. package/src/add/add-trigger.ts +0 -165
  77. package/src/add/add-variable.ts +0 -10
  78. package/src/add/add-wire-addon.test.ts +0 -185
  79. package/src/add/add-wire-addon.ts +0 -146
  80. package/src/add/add-wire-remote-addon.ts +0 -80
  81. package/src/add/add-workflow-edge-cases.test.ts +0 -201
  82. package/src/add/add-workflow-fanout.test.ts +0 -166
  83. package/src/add/add-workflow-graph-foreach.test.ts +0 -174
  84. package/src/add/add-workflow-graph-input.test.ts +0 -94
  85. package/src/add/add-workflow-graph-notes.test.ts +0 -74
  86. package/src/add/add-workflow-graph.ts +0 -644
  87. package/src/add/add-workflow-on-error.test.ts +0 -118
  88. package/src/add/add-workflow-return-switch.test.ts +0 -121
  89. package/src/add/add-workflow-silent-drops.test.ts +0 -261
  90. package/src/add/add-workflow-sleep-expression.test.ts +0 -96
  91. package/src/add/add-workflow.test.ts +0 -155
  92. package/src/add/add-workflow.ts +0 -482
  93. package/src/add/addon-bans.test.ts +0 -121
  94. package/src/add/addon-contracts.test.ts +0 -221
  95. package/src/add/allow-escape-hatches.test.ts +0 -144
  96. package/src/add/audit-meta.test.ts +0 -75
  97. package/src/add/classified-sink-check.test.ts +0 -347
  98. package/src/add/config-value-metadata.test.ts +0 -263
  99. package/src/add/dynamic-import-check.test.ts +0 -119
  100. package/src/add/expect-eventually-scenario-only.test.ts +0 -108
  101. package/src/add/function-body-span.test.ts +0 -96
  102. package/src/add/inline-wiring-function.test.ts +0 -104
  103. package/src/add/pii-check.test.ts +0 -258
  104. package/src/add/rpc-type-cast.test.ts +0 -123
  105. package/src/add/scenario-instrumentation.test.ts +0 -89
  106. package/src/add/scenario-instrumentation.ts +0 -42
  107. package/src/add/scenario-marker.test.ts +0 -89
  108. package/src/add/scenario-not-extractable.test.ts +0 -159
  109. package/src/add/scenario-services.test.ts +0 -103
  110. package/src/add/scenario-skip.test.ts +0 -83
  111. package/src/add/validate-workflow-graph-addons.test.ts +0 -102
  112. package/src/add/wire-name-literal.test.ts +0 -117
  113. package/src/error-codes-emitted.test.ts +0 -76
  114. package/src/error-codes.ts +0 -145
  115. package/src/index.ts +0 -66
  116. package/src/inspector.ts +0 -575
  117. package/src/schema-generator.ts +0 -1
  118. package/src/source-file-cache.ts +0 -73
  119. package/src/tsconfig-paths.test.ts +0 -90
  120. package/src/types-map.ts +0 -141
  121. package/src/types.ts +0 -775
  122. package/src/utils/annotate-http-route-auth.test.ts +0 -161
  123. package/src/utils/annotate-http-route-auth.ts +0 -53
  124. package/src/utils/check-classified-sinks.ts +0 -215
  125. package/src/utils/check-pii-output.ts +0 -120
  126. package/src/utils/compute-diagnostics.test.ts +0 -106
  127. package/src/utils/compute-required-schemas.ts +0 -48
  128. package/src/utils/contract-hashes.test.ts +0 -553
  129. package/src/utils/contract-hashes.ts +0 -387
  130. package/src/utils/custom-types-generator.test.ts +0 -124
  131. package/src/utils/custom-types-generator.ts +0 -150
  132. package/src/utils/detect-schema-vendor.test.ts +0 -123
  133. package/src/utils/detect-schema-vendor.ts +0 -120
  134. package/src/utils/does-type-extend-core-type.ts +0 -53
  135. package/src/utils/ensure-function-metadata.ts +0 -385
  136. package/src/utils/extract-function-name.ts +0 -612
  137. package/src/utils/extract-function-node.ts +0 -70
  138. package/src/utils/extract-node-value.test.ts +0 -117
  139. package/src/utils/extract-node-value.ts +0 -172
  140. package/src/utils/extract-secret-usage.test.ts +0 -81
  141. package/src/utils/extract-secret-usage.ts +0 -73
  142. package/src/utils/extract-services.ts +0 -70
  143. package/src/utils/extract-surface-usage.test.ts +0 -147
  144. package/src/utils/extract-surface-usage.ts +0 -121
  145. package/src/utils/filter-inspector-state.test.ts +0 -2232
  146. package/src/utils/filter-inspector-state.ts +0 -1298
  147. package/src/utils/filter-utils.test.ts +0 -843
  148. package/src/utils/filter-utils.ts +0 -161
  149. package/src/utils/find-root-dir.ts +0 -68
  150. package/src/utils/find-zod-transform.test.ts +0 -70
  151. package/src/utils/find-zod-transform.ts +0 -103
  152. package/src/utils/get-exported-variable-name.ts +0 -48
  153. package/src/utils/get-files-and-methods.ts +0 -157
  154. package/src/utils/get-property-value.test.ts +0 -141
  155. package/src/utils/get-property-value.ts +0 -331
  156. package/src/utils/hash.ts +0 -26
  157. package/src/utils/literal-properties.ts +0 -97
  158. package/src/utils/load-addon-functions-meta.test.ts +0 -157
  159. package/src/utils/load-addon-functions-meta.ts +0 -508
  160. package/src/utils/load-addon-scopes.test.ts +0 -138
  161. package/src/utils/middleware.test.ts +0 -204
  162. package/src/utils/middleware.ts +0 -303
  163. package/src/utils/nested-project-filter.test.ts +0 -53
  164. package/src/utils/nested-project-filter.ts +0 -28
  165. package/src/utils/permissions-shorthand.test.ts +0 -79
  166. package/src/utils/permissions.test.ts +0 -125
  167. package/src/utils/permissions.ts +0 -129
  168. package/src/utils/post-process.test.ts +0 -1094
  169. package/src/utils/post-process.ts +0 -1604
  170. package/src/utils/resolve-addon-package.ts +0 -106
  171. package/src/utils/resolve-core-type.test.ts +0 -142
  172. package/src/utils/resolve-core-type.ts +0 -35
  173. package/src/utils/resolve-deploy-target.test.ts +0 -135
  174. package/src/utils/resolve-deploy-target.ts +0 -65
  175. package/src/utils/resolve-function-meta.ts +0 -25
  176. package/src/utils/resolve-function-types.ts +0 -42
  177. package/src/utils/resolve-identifier.ts +0 -46
  178. package/src/utils/resolve-ref-contract.ts +0 -71
  179. package/src/utils/resolve-versions.test.ts +0 -499
  180. package/src/utils/resolve-versions.ts +0 -228
  181. package/src/utils/schema-cache.test.ts +0 -124
  182. package/src/utils/schema-generator.test.ts +0 -272
  183. package/src/utils/schema-generator.ts +0 -780
  184. package/src/utils/secret-alias-services.test.ts +0 -146
  185. package/src/utils/secret-alias-services.ts +0 -58
  186. package/src/utils/serialize-inspector-state.ts +0 -742
  187. package/src/utils/serialize-mcp-json.ts +0 -150
  188. package/src/utils/serialize-middleware-groups-meta.ts +0 -36
  189. package/src/utils/serialize-openapi-json.ts +0 -277
  190. package/src/utils/serialize-permissions-groups-meta.ts +0 -7
  191. package/src/utils/single-declaration.ts +0 -57
  192. package/src/utils/type-utils.ts +0 -145
  193. package/src/utils/validate-auth-sessionless.ts +0 -30
  194. package/src/utils/validate-exposed-functions-gated.test.ts +0 -230
  195. package/src/utils/validate-exposed-functions-gated.ts +0 -103
  196. package/src/utils/validate-tags-resolve-to-middleware.test.ts +0 -108
  197. package/src/utils/validate-tags-resolve-to-middleware.ts +0 -48
  198. package/src/utils/workflow/derive-workflow-plan.test.ts +0 -178
  199. package/src/utils/workflow/derive-workflow-plan.ts +0 -117
  200. package/src/utils/workflow/dsl/deserialize-dsl-workflow.ts +0 -1465
  201. package/src/utils/workflow/dsl/deserialize-fanout-body.test.ts +0 -71
  202. package/src/utils/workflow/dsl/deserialize-fanout-fidelity.test.ts +0 -99
  203. package/src/utils/workflow/dsl/deserialize-graph-next.test.ts +0 -161
  204. package/src/utils/workflow/dsl/deserialize-roundtrip-bindings.test.ts +0 -121
  205. package/src/utils/workflow/dsl/deserialize-roundtrip-values.test.ts +0 -116
  206. package/src/utils/workflow/dsl/deserialize-roundtrip.test.ts +0 -145
  207. package/src/utils/workflow/dsl/extract-approval-policy.test.ts +0 -71
  208. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +0 -2254
  209. package/src/utils/workflow/dsl/index.ts +0 -11
  210. package/src/utils/workflow/dsl/patterns.ts +0 -440
  211. package/src/utils/workflow/dsl/validation.ts +0 -203
  212. package/src/utils/workflow/graph/convert-dsl-to-graph-skip.test.ts +0 -35
  213. package/src/utils/workflow/graph/convert-dsl-to-graph.ts +0 -543
  214. package/src/utils/workflow/graph/fanout-node-identity.test.ts +0 -105
  215. package/src/utils/workflow/graph/finalize-workflow-wires.ts +0 -310
  216. package/src/utils/workflow/graph/finalize-workflows.test.ts +0 -50
  217. package/src/utils/workflow/graph/finalize-workflows.ts +0 -130
  218. package/src/utils/workflow/graph/index.ts +0 -28
  219. package/src/utils/workflow/graph/serialize-workflow-graph.ts +0 -240
  220. package/src/utils/workflow/graph/workflow-graph.types.ts +0 -281
  221. package/src/visit.ts +0 -181
  222. package/tsconfig.json +0 -19
  223. 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
- }