@pikku/inspector 0.11.0 → 0.11.2

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 (109) hide show
  1. package/CHANGELOG.md +32 -2
  2. package/dist/add/add-channel.js +11 -10
  3. package/dist/add/add-file-with-factory.js +10 -10
  4. package/dist/add/add-forge-credential.d.ts +8 -0
  5. package/dist/add/add-forge-credential.js +77 -0
  6. package/dist/add/add-forge-node.d.ts +7 -0
  7. package/dist/add/add-forge-node.js +77 -0
  8. package/dist/add/add-functions.js +158 -51
  9. package/dist/add/add-http-route.js +28 -4
  10. package/dist/add/add-mcp-prompt.js +6 -5
  11. package/dist/add/add-mcp-resource.js +6 -5
  12. package/dist/add/add-mcp-tool.js +6 -5
  13. package/dist/add/add-middleware.js +1 -1
  14. package/dist/add/add-permission.js +1 -1
  15. package/dist/add/add-queue-worker.js +6 -5
  16. package/dist/add/add-rpc-invocations.d.ts +3 -0
  17. package/dist/add/add-rpc-invocations.js +51 -25
  18. package/dist/add/add-schedule.js +5 -4
  19. package/dist/add/add-workflow-graph.d.ts +6 -0
  20. package/dist/add/add-workflow-graph.js +659 -0
  21. package/dist/add/add-workflow.d.ts +1 -1
  22. package/dist/add/add-workflow.js +191 -69
  23. package/dist/error-codes.d.ts +3 -0
  24. package/dist/error-codes.js +3 -0
  25. package/dist/index.d.ts +5 -0
  26. package/dist/index.js +3 -0
  27. package/dist/inspector.js +29 -9
  28. package/dist/types.d.ts +47 -8
  29. package/dist/utils/extract-function-name.js +7 -7
  30. package/dist/utils/extract-function-node.d.ts +10 -0
  31. package/dist/utils/extract-function-node.js +38 -0
  32. package/dist/utils/extract-node-value.d.ts +8 -0
  33. package/dist/utils/extract-node-value.js +24 -0
  34. package/dist/utils/extract-service-metadata.d.ts +19 -0
  35. package/dist/utils/extract-service-metadata.js +244 -0
  36. package/dist/utils/get-files-and-methods.d.ts +3 -3
  37. package/dist/utils/get-files-and-methods.js +3 -3
  38. package/dist/utils/get-property-value.d.ts +14 -6
  39. package/dist/utils/get-property-value.js +55 -43
  40. package/dist/utils/post-process.d.ts +9 -0
  41. package/dist/utils/post-process.js +30 -3
  42. package/dist/utils/serialize-inspector-state.d.ts +42 -6
  43. package/dist/utils/serialize-inspector-state.js +36 -10
  44. package/dist/utils/workflow/dsl/deserialize-dsl-workflow.d.ts +24 -0
  45. package/dist/utils/workflow/dsl/deserialize-dsl-workflow.js +898 -0
  46. package/dist/utils/workflow/dsl/extract-dsl-workflow.d.ts +17 -0
  47. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +1284 -0
  48. package/dist/utils/workflow/dsl/index.d.ts +7 -0
  49. package/dist/utils/workflow/dsl/index.js +7 -0
  50. package/dist/utils/workflow/dsl/patterns.d.ts +60 -0
  51. package/dist/utils/workflow/dsl/patterns.js +218 -0
  52. package/dist/utils/workflow/dsl/validation.d.ts +30 -0
  53. package/dist/utils/workflow/dsl/validation.js +142 -0
  54. package/dist/utils/workflow/graph/convert-dsl-to-graph.d.ts +13 -0
  55. package/dist/utils/workflow/graph/convert-dsl-to-graph.js +316 -0
  56. package/dist/utils/workflow/graph/index.d.ts +6 -0
  57. package/dist/utils/workflow/graph/index.js +6 -0
  58. package/dist/utils/workflow/graph/serialize-workflow-graph.d.ts +43 -0
  59. package/dist/utils/workflow/graph/serialize-workflow-graph.js +152 -0
  60. package/dist/utils/workflow/graph/workflow-graph.types.d.ts +229 -0
  61. package/dist/utils/workflow/graph/workflow-graph.types.js +38 -0
  62. package/dist/utils/write-service-metadata.d.ts +13 -0
  63. package/dist/utils/write-service-metadata.js +37 -0
  64. package/dist/visit.js +8 -2
  65. package/package.json +16 -4
  66. package/src/add/add-channel.ts +37 -17
  67. package/src/add/add-file-with-factory.ts +10 -10
  68. package/src/add/add-forge-credential.ts +119 -0
  69. package/src/add/add-forge-node.ts +132 -0
  70. package/src/add/add-functions.ts +199 -69
  71. package/src/add/add-http-route.ts +34 -5
  72. package/src/add/add-mcp-prompt.ts +11 -7
  73. package/src/add/add-mcp-resource.ts +11 -7
  74. package/src/add/add-mcp-tool.ts +11 -7
  75. package/src/add/add-middleware.ts +1 -1
  76. package/src/add/add-permission.ts +1 -1
  77. package/src/add/add-queue-worker.ts +11 -12
  78. package/src/add/add-rpc-invocations.ts +61 -31
  79. package/src/add/add-schedule.ts +10 -5
  80. package/src/add/add-workflow-graph.ts +864 -0
  81. package/src/add/add-workflow.ts +212 -116
  82. package/src/error-codes.ts +3 -0
  83. package/src/index.ts +12 -0
  84. package/src/inspector.ts +36 -10
  85. package/src/types.ts +43 -9
  86. package/src/utils/extract-function-name.ts +7 -7
  87. package/src/utils/extract-function-node.ts +58 -0
  88. package/src/utils/extract-node-value.ts +31 -0
  89. package/src/utils/extract-service-metadata.ts +353 -0
  90. package/src/utils/filter-inspector-state.test.ts +3 -3
  91. package/src/utils/filter-utils.test.ts +45 -51
  92. package/src/utils/get-files-and-methods.ts +11 -11
  93. package/src/utils/get-property-value.ts +67 -53
  94. package/src/utils/permissions.test.ts +3 -3
  95. package/src/utils/post-process.ts +56 -3
  96. package/src/utils/serialize-inspector-state.ts +67 -19
  97. package/src/utils/test-data/inspector-state.json +9 -9
  98. package/src/utils/workflow/dsl/deserialize-dsl-workflow.ts +1180 -0
  99. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +1608 -0
  100. package/src/utils/workflow/dsl/index.ts +11 -0
  101. package/src/utils/workflow/dsl/patterns.ts +279 -0
  102. package/src/utils/workflow/dsl/validation.ts +180 -0
  103. package/src/utils/workflow/graph/convert-dsl-to-graph.ts +415 -0
  104. package/src/utils/workflow/graph/index.ts +6 -0
  105. package/src/utils/workflow/graph/serialize-workflow-graph.ts +223 -0
  106. package/src/utils/workflow/graph/workflow-graph.types.ts +280 -0
  107. package/src/utils/write-service-metadata.ts +51 -0
  108. package/src/visit.ts +9 -3
  109. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,1608 @@
1
+ import * as ts from 'typescript'
2
+ import {
3
+ WorkflowStepMeta,
4
+ RpcStepMeta,
5
+ BranchStepMeta,
6
+ ParallelGroupStepMeta,
7
+ FanoutStepMeta,
8
+ ReturnStepMeta,
9
+ CancelStepMeta,
10
+ SetStepMeta,
11
+ SwitchStepMeta,
12
+ SwitchCaseMeta,
13
+ FilterStepMeta,
14
+ ArrayPredicateStepMeta,
15
+ InputSource,
16
+ OutputBinding,
17
+ Condition,
18
+ WorkflowContext,
19
+ ContextVariable,
20
+ } from '@pikku/core/workflow'
21
+ import {
22
+ isWorkflowDoCall,
23
+ isWorkflowSleepCall,
24
+ isThrowCancelException,
25
+ extractCancelReason,
26
+ isParallelFanout,
27
+ isParallelGroup,
28
+ isSequentialFanout,
29
+ isArrayFilter,
30
+ isArraySome,
31
+ isArrayEvery,
32
+ extractForOfVariable,
33
+ isArrayType,
34
+ getSourceText,
35
+ } from './patterns.js'
36
+ import {
37
+ validateNoDisallowedPatterns,
38
+ validateAwaitedCalls,
39
+ formatValidationErrors,
40
+ ValidationError,
41
+ } from './validation.js'
42
+ import {
43
+ extractStringLiteral,
44
+ extractNumberLiteral,
45
+ } from '../../extract-node-value.js'
46
+
47
+ /**
48
+ * Extraction context to track state during AST traversal
49
+ */
50
+ interface ExtractionContext {
51
+ checker: ts.TypeChecker
52
+ outputVars: Map<string, { type: ts.Type; node: ts.Node }>
53
+ arrayVars: Set<string>
54
+ conditionalVars: Set<string>
55
+ inputParamName: string
56
+ errors: ValidationError[]
57
+ /** Loop variables in scope (for fanout item variables) */
58
+ loopVars: Set<string>
59
+ /** Context variables (top-level let/const with simple values) */
60
+ contextVars: Map<string, { type: string; default: unknown }>
61
+ /** Track nesting depth to detect block-scoped vars */
62
+ depth: number
63
+ }
64
+
65
+ /**
66
+ * Extract full source path from an expression (e.g., data.memberEmails)
67
+ */
68
+ function extractSourcePath(expr: ts.Expression): string | null {
69
+ if (ts.isIdentifier(expr)) {
70
+ return expr.text
71
+ }
72
+
73
+ if (ts.isPropertyAccessExpression(expr)) {
74
+ const base = extractSourcePath(expr.expression)
75
+ if (base) {
76
+ return `${base}.${expr.name.text}`
77
+ }
78
+ }
79
+
80
+ return null
81
+ }
82
+
83
+ /**
84
+ * Result of simple workflow extraction
85
+ */
86
+ export interface ExtractionResult {
87
+ status: 'ok' | 'error'
88
+ steps?: WorkflowStepMeta[]
89
+ /** Workflow context (top-level variables) */
90
+ context?: WorkflowContext
91
+ reason?: string
92
+ simple?: boolean
93
+ }
94
+
95
+ /**
96
+ * Extract simple workflow metadata from a function declaration
97
+ */
98
+ export function extractDSLWorkflow(
99
+ funcNode: ts.Node,
100
+ checker: ts.TypeChecker
101
+ ): ExtractionResult {
102
+ try {
103
+ // Find the async arrow function
104
+ const arrowFunc = findWorkflowFunction(funcNode)
105
+ if (!arrowFunc) {
106
+ return {
107
+ status: 'error',
108
+ reason: 'Could not find async arrow function in workflow definition',
109
+ simple: false,
110
+ }
111
+ }
112
+
113
+ // Extract input parameter name (second parameter)
114
+ const inputParamName = extractInputParamName(arrowFunc)
115
+ if (!inputParamName) {
116
+ return {
117
+ status: 'error',
118
+ reason: 'Could not determine input parameter name',
119
+ simple: false,
120
+ }
121
+ }
122
+
123
+ // Initialize extraction context
124
+ const context: ExtractionContext = {
125
+ checker,
126
+ outputVars: new Map(),
127
+ arrayVars: new Set(),
128
+ conditionalVars: new Set(),
129
+ inputParamName,
130
+ errors: [],
131
+ loopVars: new Set(),
132
+ contextVars: new Map(),
133
+ depth: 0,
134
+ }
135
+
136
+ // Validate no disallowed patterns
137
+ const patternErrors = validateNoDisallowedPatterns(arrowFunc.body)
138
+ if (patternErrors.length > 0) {
139
+ return {
140
+ status: 'error',
141
+ reason: formatValidationErrors(patternErrors),
142
+ simple: false,
143
+ }
144
+ }
145
+
146
+ // Validate all workflow calls are awaited
147
+ const awaitErrors = validateAwaitedCalls(arrowFunc.body)
148
+ if (awaitErrors.length > 0) {
149
+ return {
150
+ status: 'error',
151
+ reason: formatValidationErrors(awaitErrors),
152
+ simple: false,
153
+ }
154
+ }
155
+
156
+ // Extract steps from function body
157
+ const steps = extractSteps(arrowFunc.body, context)
158
+
159
+ // Check for any accumulated errors
160
+ if (context.errors.length > 0) {
161
+ return {
162
+ status: 'error',
163
+ reason: formatValidationErrors(context.errors),
164
+ simple: false,
165
+ }
166
+ }
167
+
168
+ // Build workflow context from extracted context variables
169
+ const workflowContext: WorkflowContext = {}
170
+ for (const [name, info] of context.contextVars) {
171
+ workflowContext[name] = {
172
+ type: info.type as ContextVariable['type'],
173
+ default: info.default,
174
+ }
175
+ }
176
+
177
+ return {
178
+ status: 'ok',
179
+ steps,
180
+ context:
181
+ Object.keys(workflowContext).length > 0 ? workflowContext : undefined,
182
+ simple: true,
183
+ }
184
+ } catch (error) {
185
+ return {
186
+ status: 'error',
187
+ reason: error instanceof Error ? error.message : String(error),
188
+ simple: false,
189
+ }
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Find the workflow function (async arrow function)
195
+ */
196
+ function findWorkflowFunction(node: ts.Node): ts.ArrowFunction | null {
197
+ // Handle pikkuWorkflowFunc(async () => {}) or pikkuWorkflowComplexFunc(async () => {})
198
+ if (ts.isCallExpression(node)) {
199
+ const arg = node.arguments[0]
200
+ if (arg && ts.isArrowFunction(arg)) {
201
+ return arg
202
+ }
203
+ // Also check if first argument is an object with func property
204
+ if (arg && ts.isObjectLiteralExpression(arg)) {
205
+ for (const prop of arg.properties) {
206
+ if (
207
+ ts.isPropertyAssignment(prop) &&
208
+ ts.isIdentifier(prop.name) &&
209
+ prop.name.text === 'func'
210
+ ) {
211
+ if (ts.isArrowFunction(prop.initializer)) {
212
+ return prop.initializer
213
+ }
214
+ }
215
+ }
216
+ }
217
+ }
218
+
219
+ // Handle pikkuWorkflowFunc({ func: async () => {} })
220
+ if (ts.isObjectLiteralExpression(node)) {
221
+ for (const prop of node.properties) {
222
+ if (
223
+ ts.isPropertyAssignment(prop) &&
224
+ ts.isIdentifier(prop.name) &&
225
+ prop.name.text === 'func'
226
+ ) {
227
+ if (ts.isArrowFunction(prop.initializer)) {
228
+ return prop.initializer
229
+ }
230
+ }
231
+ }
232
+ }
233
+
234
+ return null
235
+ }
236
+
237
+ /**
238
+ * Extract the input parameter name from the arrow function
239
+ */
240
+ function extractInputParamName(arrowFunc: ts.ArrowFunction): string | null {
241
+ if (arrowFunc.parameters.length < 2) {
242
+ return null
243
+ }
244
+
245
+ const secondParam = arrowFunc.parameters[1]
246
+ if (ts.isIdentifier(secondParam.name)) {
247
+ return secondParam.name.text
248
+ }
249
+
250
+ return null
251
+ }
252
+
253
+ /**
254
+ * Extract steps from the function body
255
+ */
256
+ function extractSteps(
257
+ body: ts.Node,
258
+ context: ExtractionContext,
259
+ incrementDepth = false
260
+ ): WorkflowStepMeta[] {
261
+ const steps: WorkflowStepMeta[] = []
262
+
263
+ if (!ts.isBlock(body)) {
264
+ return steps
265
+ }
266
+
267
+ // Increment depth when entering a nested block
268
+ if (incrementDepth) {
269
+ context.depth++
270
+ }
271
+
272
+ for (const statement of body.statements) {
273
+ const extracted = extractStep(statement, context)
274
+ if (extracted) {
275
+ steps.push(extracted)
276
+ }
277
+ }
278
+
279
+ // Restore depth
280
+ if (incrementDepth) {
281
+ context.depth--
282
+ }
283
+
284
+ return steps
285
+ }
286
+
287
+ /**
288
+ * Extract a single step from a statement
289
+ */
290
+ function extractStep(
291
+ statement: ts.Statement,
292
+ context: ExtractionContext
293
+ ): WorkflowStepMeta | null {
294
+ // Variable declaration with workflow.do assignment
295
+ if (ts.isVariableStatement(statement)) {
296
+ return extractVariableDeclaration(statement, context)
297
+ }
298
+
299
+ // Expression statement (await workflow.do without assignment)
300
+ if (ts.isExpressionStatement(statement)) {
301
+ return extractExpressionStatement(statement, context)
302
+ }
303
+
304
+ // If statement (branch)
305
+ if (ts.isIfStatement(statement)) {
306
+ return extractBranch(statement, context)
307
+ }
308
+
309
+ // Switch statement
310
+ if (ts.isSwitchStatement(statement)) {
311
+ return extractSwitch(statement, context)
312
+ }
313
+
314
+ // For-of statement (sequential fanout)
315
+ if (ts.isForOfStatement(statement)) {
316
+ return extractSequentialFanout(statement, context)
317
+ }
318
+
319
+ // Return statement
320
+ if (ts.isReturnStatement(statement)) {
321
+ return extractReturn(statement, context)
322
+ }
323
+
324
+ // Throw statement (for WorkflowCancelledException)
325
+ if (ts.isThrowStatement(statement)) {
326
+ return extractThrowCancel(statement, context)
327
+ }
328
+
329
+ return null
330
+ }
331
+
332
+ /**
333
+ * Extract variable declaration (const x = await workflow.do(...))
334
+ */
335
+ function extractVariableDeclaration(
336
+ statement: ts.VariableStatement,
337
+ context: ExtractionContext
338
+ ): WorkflowStepMeta | null {
339
+ const declList = statement.declarationList
340
+ if (declList.declarations.length !== 1) {
341
+ return null
342
+ }
343
+
344
+ const decl = declList.declarations[0]
345
+ if (!ts.isIdentifier(decl.name)) {
346
+ return null
347
+ }
348
+
349
+ const varName = decl.name.text
350
+ const init = decl.initializer
351
+
352
+ // Check for block-scoped variable declarations (not allowed)
353
+ if (context.depth > 0) {
354
+ context.errors.push({
355
+ message: `Variable declaration '${varName}' inside block is not supported in DSL workflows. Move all let/const declarations to the top level.`,
356
+ node: statement,
357
+ })
358
+ return null
359
+ }
360
+
361
+ if (!init) {
362
+ return null
363
+ }
364
+
365
+ // Check for simple literal/expression context variable (let x = 'value')
366
+ const literalValue = extractLiteralValue(init)
367
+ if (literalValue !== undefined) {
368
+ const tsType = context.checker.getTypeAtLocation(decl)
369
+ const typeStr = inferSimpleType(tsType, context.checker)
370
+ context.contextVars.set(varName, { type: typeStr, default: literalValue })
371
+ return null // No step emitted, just register the context var
372
+ }
373
+
374
+ // Check for await workflow.do(...)
375
+ if (ts.isAwaitExpression(init) && ts.isCallExpression(init.expression)) {
376
+ const call = init.expression
377
+ if (isWorkflowDoCall(call, context.checker)) {
378
+ const step = extractRpcStep(call, context, varName)
379
+ if (step) {
380
+ // Track output variable
381
+ const type = context.checker.getTypeAtLocation(decl)
382
+ context.outputVars.set(varName, { type, node: decl })
383
+
384
+ // Check if it's an array type
385
+ if (isArrayType(type, context.checker)) {
386
+ context.arrayVars.add(varName)
387
+ }
388
+
389
+ // Check if it's a conditional variable (let x: T | undefined)
390
+ if (declList.flags & ts.NodeFlags.Let) {
391
+ const typeNode = decl.type
392
+ if (typeNode && ts.isUnionTypeNode(typeNode)) {
393
+ // Check if union includes undefined
394
+ const hasUndefined = typeNode.types.some(
395
+ (t) =>
396
+ (ts.isLiteralTypeNode(t) &&
397
+ t.literal.kind === ts.SyntaxKind.UndefinedKeyword) ||
398
+ t.kind === ts.SyntaxKind.UndefinedKeyword
399
+ )
400
+ if (hasUndefined) {
401
+ context.conditionalVars.add(varName)
402
+ }
403
+ }
404
+ }
405
+
406
+ return step
407
+ }
408
+ }
409
+ }
410
+
411
+ // Check for array.filter(...)
412
+ if (ts.isCallExpression(init)) {
413
+ if (isArrayFilter(init)) {
414
+ const filterStep = extractArrayFilter(init, context, varName)
415
+ if (filterStep) {
416
+ const type = context.checker.getTypeAtLocation(decl)
417
+ context.outputVars.set(varName, { type, node: decl })
418
+ if (isArrayType(type, context.checker)) {
419
+ context.arrayVars.add(varName)
420
+ }
421
+ return filterStep
422
+ }
423
+ }
424
+
425
+ if (isArraySome(init) || isArrayEvery(init)) {
426
+ const predicateStep = extractArrayPredicate(init, context, varName)
427
+ if (predicateStep) {
428
+ const type = context.checker.getTypeAtLocation(decl)
429
+ context.outputVars.set(varName, { type, node: decl })
430
+ return predicateStep
431
+ }
432
+ }
433
+ }
434
+
435
+ return null
436
+ }
437
+
438
+ /**
439
+ * Extract expression statement (await workflow.do(...) without assignment)
440
+ */
441
+ function extractExpressionStatement(
442
+ statement: ts.ExpressionStatement,
443
+ context: ExtractionContext
444
+ ): WorkflowStepMeta | null {
445
+ let expr = statement.expression
446
+
447
+ // Handle assignment: x = value or x = await workflow.do(...)
448
+ let outputVar: string | undefined
449
+ if (
450
+ ts.isBinaryExpression(expr) &&
451
+ expr.operatorToken.kind === ts.SyntaxKind.EqualsToken
452
+ ) {
453
+ // Extract variable name from left side
454
+ if (ts.isIdentifier(expr.left)) {
455
+ outputVar = expr.left.text
456
+
457
+ // Check if this is an assignment to a context variable (set step)
458
+ if (context.contextVars.has(outputVar)) {
459
+ const literalValue = extractLiteralValue(expr.right)
460
+ if (literalValue !== undefined) {
461
+ return {
462
+ type: 'set',
463
+ variable: outputVar,
464
+ value: literalValue,
465
+ } as SetStepMeta
466
+ }
467
+ // Non-literal assignment to context var - use expression as string
468
+ return {
469
+ type: 'set',
470
+ variable: outputVar,
471
+ value: getSourceText(expr.right),
472
+ } as SetStepMeta
473
+ }
474
+ }
475
+ // Use right side as the expression to extract from
476
+ expr = expr.right
477
+ }
478
+
479
+ // await workflow.do(...)
480
+ if (ts.isAwaitExpression(expr) && ts.isCallExpression(expr.expression)) {
481
+ const call = expr.expression
482
+
483
+ if (isWorkflowDoCall(call, context.checker)) {
484
+ const step = extractRpcStep(call, context, outputVar)
485
+
486
+ // Track output variable if this is an assignment
487
+ if (outputVar && step) {
488
+ const type = context.checker.getTypeAtLocation(expr)
489
+ context.outputVars.set(outputVar, { type, node: expr })
490
+
491
+ // Check if it's an array type
492
+ if (isArrayType(type, context.checker)) {
493
+ context.arrayVars.add(outputVar)
494
+ }
495
+ }
496
+
497
+ return step
498
+ }
499
+
500
+ if (isWorkflowSleepCall(call, context.checker)) {
501
+ return extractSleepStep(call, context)
502
+ }
503
+
504
+ // Check for parallel group or fanout
505
+ if (isParallelFanout(call)) {
506
+ return extractParallelFanout(call, context)
507
+ }
508
+
509
+ if (isParallelGroup(call)) {
510
+ return extractParallelGroup(call, context)
511
+ }
512
+ }
513
+
514
+ return null
515
+ }
516
+
517
+ /**
518
+ * Extract RPC step from workflow.do() call
519
+ */
520
+ function extractRpcStep(
521
+ call: ts.CallExpression,
522
+ context: ExtractionContext,
523
+ outputVar?: string
524
+ ): RpcStepMeta | null {
525
+ const args = call.arguments
526
+
527
+ if (args.length < 2) {
528
+ return null
529
+ }
530
+
531
+ try {
532
+ const stepName = extractStringLiteral(args[0], context.checker)
533
+ const rpcName = extractStringLiteral(args[1], context.checker)
534
+
535
+ // Extract inputs from third argument
536
+ const inputs =
537
+ args.length >= 3 ? extractInputSources(args[2], context) : undefined
538
+
539
+ // Extract options from fourth argument
540
+ const options =
541
+ args.length >= 4 && ts.isObjectLiteralExpression(args[3])
542
+ ? extractStepOptions(args[3], context)
543
+ : undefined
544
+
545
+ return {
546
+ type: 'rpc',
547
+ stepName,
548
+ rpcName,
549
+ outputVar,
550
+ inputs,
551
+ options,
552
+ }
553
+ } catch (error) {
554
+ context.errors.push({
555
+ message: `Failed to extract RPC step: ${error instanceof Error ? error.message : String(error)}`,
556
+ node: call,
557
+ })
558
+ return null
559
+ }
560
+ }
561
+
562
+ /**
563
+ * Extract step options from options object
564
+ */
565
+ function extractStepOptions(
566
+ optionsNode: ts.ObjectLiteralExpression,
567
+ context: ExtractionContext
568
+ ): RpcStepMeta['options'] {
569
+ const options: RpcStepMeta['options'] = {}
570
+
571
+ for (const prop of optionsNode.properties) {
572
+ if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) {
573
+ const propName = prop.name.text
574
+
575
+ if (propName === 'retries') {
576
+ const retries = extractNumberLiteral(prop.initializer)
577
+ if (retries !== null) {
578
+ options.retries = retries
579
+ }
580
+ } else if (propName === 'retryDelay') {
581
+ try {
582
+ if (ts.isStringLiteral(prop.initializer)) {
583
+ options.retryDelay = prop.initializer.text
584
+ } else {
585
+ const delay = extractNumberLiteral(prop.initializer)
586
+ if (delay !== null) {
587
+ options.retryDelay = delay
588
+ }
589
+ }
590
+ } catch {
591
+ // Ignore extraction errors for retryDelay
592
+ }
593
+ } else if (propName === 'description') {
594
+ try {
595
+ options.description = extractStringLiteral(
596
+ prop.initializer,
597
+ context.checker
598
+ )
599
+ } catch {
600
+ // Ignore extraction errors for description
601
+ }
602
+ }
603
+ }
604
+ }
605
+
606
+ return Object.keys(options).length > 0 ? options : undefined
607
+ }
608
+
609
+ /**
610
+ * Extract sleep step from workflow.sleep() call
611
+ */
612
+ function extractSleepStep(
613
+ call: ts.CallExpression,
614
+ context: ExtractionContext
615
+ ): WorkflowStepMeta | null {
616
+ const args = call.arguments
617
+
618
+ if (args.length < 2) {
619
+ return null
620
+ }
621
+
622
+ try {
623
+ const stepName = extractStringLiteral(args[0], context.checker)
624
+ let duration: string | number
625
+
626
+ const numValue = extractNumberLiteral(args[1])
627
+ if (numValue !== null) {
628
+ duration = numValue
629
+ } else {
630
+ duration = extractStringLiteral(args[1], context.checker)
631
+ }
632
+
633
+ return {
634
+ type: 'sleep',
635
+ stepName,
636
+ duration,
637
+ }
638
+ } catch (error) {
639
+ context.errors.push({
640
+ message: `Failed to extract sleep step: ${error instanceof Error ? error.message : String(error)}`,
641
+ node: call,
642
+ })
643
+ return null
644
+ }
645
+ }
646
+
647
+ /**
648
+ * Extract cancel step from throw WorkflowCancelledException statement
649
+ */
650
+ function extractThrowCancel(
651
+ statement: ts.ThrowStatement,
652
+ context: ExtractionContext
653
+ ): CancelStepMeta | null {
654
+ if (!isThrowCancelException(statement)) {
655
+ return null
656
+ }
657
+
658
+ const reason = extractCancelReason(statement, context.checker)
659
+ return {
660
+ type: 'cancel',
661
+ reason,
662
+ }
663
+ }
664
+
665
+ /**
666
+ * Parse a condition expression into a Condition structure
667
+ */
668
+ function parseCondition(expr: ts.Expression): Condition {
669
+ // Handle binary expressions (&&, ||)
670
+ if (ts.isBinaryExpression(expr)) {
671
+ const operator = expr.operatorToken.kind
672
+
673
+ // AND operator (&&)
674
+ if (operator === ts.SyntaxKind.AmpersandAmpersandToken) {
675
+ return {
676
+ type: 'and',
677
+ conditions: [parseCondition(expr.left), parseCondition(expr.right)],
678
+ }
679
+ }
680
+
681
+ // OR operator (||)
682
+ if (operator === ts.SyntaxKind.BarBarToken) {
683
+ return {
684
+ type: 'or',
685
+ conditions: [parseCondition(expr.left), parseCondition(expr.right)],
686
+ }
687
+ }
688
+ }
689
+
690
+ // Handle parenthesized expressions - unwrap and parse inner
691
+ if (ts.isParenthesizedExpression(expr)) {
692
+ return parseCondition(expr.expression)
693
+ }
694
+
695
+ // Simple condition (comparison, function call, variable, etc.)
696
+ return {
697
+ type: 'simple',
698
+ expression: getSourceText(expr),
699
+ }
700
+ }
701
+
702
+ /**
703
+ * Extract branch step from if statement (supports if/else-if/else chains)
704
+ */
705
+ function extractBranch(
706
+ statement: ts.IfStatement,
707
+ context: ExtractionContext
708
+ ): BranchStepMeta | null {
709
+ const branches: BranchStepMeta['branches'] = []
710
+ let elseSteps: BranchStepMeta['elseSteps']
711
+
712
+ // Walk the if/else-if chain
713
+ let current: ts.IfStatement | undefined = statement
714
+ while (current) {
715
+ const condition = parseCondition(current.expression)
716
+ const steps = ts.isBlock(current.thenStatement)
717
+ ? extractSteps(current.thenStatement, context, true)
718
+ : extractStepsFromStatement(current.thenStatement, context)
719
+
720
+ branches.push({ condition, steps })
721
+
722
+ // Check for else-if or else
723
+ if (current.elseStatement) {
724
+ if (ts.isIfStatement(current.elseStatement)) {
725
+ // else-if: continue the chain
726
+ current = current.elseStatement
727
+ } else {
728
+ // else: extract the final else block and stop
729
+ elseSteps = ts.isBlock(current.elseStatement)
730
+ ? extractSteps(current.elseStatement, context, true)
731
+ : extractStepsFromStatement(current.elseStatement, context)
732
+ current = undefined
733
+ }
734
+ } else {
735
+ // No else clause
736
+ current = undefined
737
+ }
738
+ }
739
+
740
+ return {
741
+ type: 'branch',
742
+ branches,
743
+ elseSteps,
744
+ }
745
+ }
746
+
747
+ /**
748
+ * Extract steps from a single statement (non-block)
749
+ */
750
+ function extractStepsFromStatement(
751
+ statement: ts.Statement,
752
+ context: ExtractionContext
753
+ ): WorkflowStepMeta[] {
754
+ // Increment depth for single-statement blocks (if without braces)
755
+ context.depth++
756
+ const step = extractStep(statement, context)
757
+ context.depth--
758
+ return step ? [step] : []
759
+ }
760
+
761
+ /**
762
+ * Extract switch statement
763
+ */
764
+ function extractSwitch(
765
+ statement: ts.SwitchStatement,
766
+ context: ExtractionContext
767
+ ): SwitchStepMeta | null {
768
+ const expression = getSourceText(statement.expression)
769
+ const cases: SwitchCaseMeta[] = []
770
+ let defaultSteps: WorkflowStepMeta[] | undefined
771
+
772
+ for (const clause of statement.caseBlock.clauses) {
773
+ if (ts.isCaseClause(clause)) {
774
+ const caseValue = extractCaseValue(clause.expression)
775
+ const steps = extractCaseSteps(clause.statements, context)
776
+
777
+ cases.push({
778
+ value: caseValue.value,
779
+ expression: caseValue.expression,
780
+ steps,
781
+ })
782
+ } else if (ts.isDefaultClause(clause)) {
783
+ defaultSteps = extractCaseSteps(clause.statements, context)
784
+ }
785
+ }
786
+
787
+ return {
788
+ type: 'switch',
789
+ expression,
790
+ cases,
791
+ defaultSteps,
792
+ }
793
+ }
794
+
795
+ /**
796
+ * Extract case value from expression
797
+ */
798
+ function extractCaseValue(expr: ts.Expression): {
799
+ value?: string | number | boolean | null
800
+ expression?: string
801
+ } {
802
+ if (ts.isStringLiteral(expr)) {
803
+ return { value: expr.text }
804
+ }
805
+ if (ts.isNumericLiteral(expr)) {
806
+ return { value: Number(expr.text) }
807
+ }
808
+ if (expr.kind === ts.SyntaxKind.TrueKeyword) {
809
+ return { value: true }
810
+ }
811
+ if (expr.kind === ts.SyntaxKind.FalseKeyword) {
812
+ return { value: false }
813
+ }
814
+ if (expr.kind === ts.SyntaxKind.NullKeyword) {
815
+ return { value: null }
816
+ }
817
+
818
+ return { expression: getSourceText(expr) }
819
+ }
820
+
821
+ /**
822
+ * Extract steps from case statements, stopping at break
823
+ */
824
+ function extractCaseSteps(
825
+ statements: ts.NodeArray<ts.Statement>,
826
+ context: ExtractionContext
827
+ ): WorkflowStepMeta[] {
828
+ const steps: WorkflowStepMeta[] = []
829
+
830
+ // Increment depth for case blocks
831
+ context.depth++
832
+
833
+ for (const statement of statements) {
834
+ if (ts.isBreakStatement(statement)) {
835
+ break
836
+ }
837
+
838
+ const step = extractStep(statement, context)
839
+ if (step) {
840
+ steps.push(step)
841
+ }
842
+ }
843
+
844
+ // Restore depth
845
+ context.depth--
846
+
847
+ return steps
848
+ }
849
+
850
+ /**
851
+ * Extract array filter operation
852
+ */
853
+ function extractArrayFilter(
854
+ call: ts.CallExpression,
855
+ context: ExtractionContext,
856
+ outputVar?: string
857
+ ): FilterStepMeta | null {
858
+ if (!ts.isPropertyAccessExpression(call.expression)) {
859
+ return null
860
+ }
861
+
862
+ const sourceExpr = call.expression.expression
863
+ const sourceVar = extractSourcePath(sourceExpr)
864
+
865
+ if (!sourceVar) {
866
+ return null
867
+ }
868
+
869
+ const filterFn = call.arguments[0]
870
+ if (!filterFn || !ts.isArrowFunction(filterFn)) {
871
+ return null
872
+ }
873
+
874
+ const itemParam = filterFn.parameters[0]
875
+ if (!itemParam || !ts.isIdentifier(itemParam.name)) {
876
+ return null
877
+ }
878
+
879
+ const itemVar = itemParam.name.text
880
+
881
+ let condition: Condition
882
+ if (ts.isBlock(filterFn.body)) {
883
+ return null
884
+ } else {
885
+ condition = parseCondition(filterFn.body)
886
+ }
887
+
888
+ return {
889
+ type: 'filter',
890
+ sourceVar,
891
+ itemVar,
892
+ condition,
893
+ outputVar,
894
+ }
895
+ }
896
+
897
+ /**
898
+ * Extract array predicate operation (some/every)
899
+ */
900
+ function extractArrayPredicate(
901
+ call: ts.CallExpression,
902
+ context: ExtractionContext,
903
+ outputVar?: string
904
+ ): ArrayPredicateStepMeta | null {
905
+ if (!ts.isPropertyAccessExpression(call.expression)) {
906
+ return null
907
+ }
908
+
909
+ const mode = call.expression.name.text as 'some' | 'every'
910
+ const sourceExpr = call.expression.expression
911
+ const sourceVar = extractSourcePath(sourceExpr)
912
+
913
+ if (!sourceVar) {
914
+ return null
915
+ }
916
+
917
+ const predicateFn = call.arguments[0]
918
+ if (!predicateFn || !ts.isArrowFunction(predicateFn)) {
919
+ return null
920
+ }
921
+
922
+ const itemParam = predicateFn.parameters[0]
923
+ if (!itemParam || !ts.isIdentifier(itemParam.name)) {
924
+ return null
925
+ }
926
+
927
+ const itemVar = itemParam.name.text
928
+
929
+ let condition: Condition
930
+ if (ts.isBlock(predicateFn.body)) {
931
+ return null
932
+ } else {
933
+ condition = parseCondition(predicateFn.body)
934
+ }
935
+
936
+ return {
937
+ type: 'arrayPredicate',
938
+ mode,
939
+ sourceVar,
940
+ itemVar,
941
+ condition,
942
+ outputVar,
943
+ }
944
+ }
945
+
946
+ /**
947
+ * Extract parallel fanout from Promise.all(array.map(...))
948
+ */
949
+ function extractParallelFanout(
950
+ call: ts.CallExpression,
951
+ context: ExtractionContext
952
+ ): FanoutStepMeta | null {
953
+ const mapCall = call.arguments[0]
954
+ if (!ts.isCallExpression(mapCall)) {
955
+ return null
956
+ }
957
+
958
+ if (!ts.isPropertyAccessExpression(mapCall.expression)) {
959
+ return null
960
+ }
961
+
962
+ // Extract source array
963
+ const sourceExpr = mapCall.expression.expression
964
+ const sourceVar = extractSourcePath(sourceExpr)
965
+
966
+ if (!sourceVar) {
967
+ return null
968
+ }
969
+
970
+ // Extract map function
971
+ const mapFn = mapCall.arguments[0]
972
+ if (!ts.isArrowFunction(mapFn)) {
973
+ return null
974
+ }
975
+
976
+ // Extract item variable
977
+ const itemParam = mapFn.parameters[0]
978
+ if (!itemParam || !ts.isIdentifier(itemParam.name)) {
979
+ return null
980
+ }
981
+
982
+ const itemVar = itemParam.name.text
983
+
984
+ // Extract workflow.do call from map body
985
+ let doCall: ts.CallExpression | null = null
986
+
987
+ if (ts.isCallExpression(mapFn.body)) {
988
+ doCall = mapFn.body
989
+ } else if (ts.isAwaitExpression(mapFn.body)) {
990
+ // Handle: async (email) => await workflow.do(...)
991
+ if (ts.isCallExpression(mapFn.body.expression)) {
992
+ doCall = mapFn.body.expression
993
+ }
994
+ } else if (ts.isBlock(mapFn.body)) {
995
+ // Look for workflow.do in block
996
+ for (const stmt of mapFn.body.statements) {
997
+ if (ts.isExpressionStatement(stmt)) {
998
+ // Handle: await workflow.do(...)
999
+ if (
1000
+ ts.isAwaitExpression(stmt.expression) &&
1001
+ ts.isCallExpression(stmt.expression.expression)
1002
+ ) {
1003
+ doCall = stmt.expression.expression
1004
+ break
1005
+ }
1006
+ } else if (ts.isReturnStatement(stmt) && stmt.expression) {
1007
+ if (ts.isCallExpression(stmt.expression)) {
1008
+ doCall = stmt.expression
1009
+ break
1010
+ } else if (ts.isAwaitExpression(stmt.expression)) {
1011
+ // Handle: return await workflow.do(...)
1012
+ if (ts.isCallExpression(stmt.expression.expression)) {
1013
+ doCall = stmt.expression.expression
1014
+ break
1015
+ }
1016
+ }
1017
+ }
1018
+ }
1019
+ }
1020
+
1021
+ if (!doCall || !isWorkflowDoCall(doCall, context.checker)) {
1022
+ return null
1023
+ }
1024
+
1025
+ // Create a temporary context for the child step with the loop variable
1026
+ const childContext: ExtractionContext = {
1027
+ ...context,
1028
+ outputVars: new Map(context.outputVars),
1029
+ loopVars: new Set([...context.loopVars, itemVar]),
1030
+ }
1031
+
1032
+ const childStep = extractRpcStep(doCall, childContext)
1033
+ if (!childStep) {
1034
+ return null
1035
+ }
1036
+
1037
+ return {
1038
+ type: 'fanout',
1039
+ stepName: childStep.stepName,
1040
+ sourceVar,
1041
+ itemVar,
1042
+ mode: 'parallel',
1043
+ child: childStep,
1044
+ }
1045
+ }
1046
+
1047
+ /**
1048
+ * Extract parallel group from Promise.all([...])
1049
+ */
1050
+ function extractParallelGroup(
1051
+ call: ts.CallExpression,
1052
+ context: ExtractionContext
1053
+ ): ParallelGroupStepMeta | null {
1054
+ const arrayArg = call.arguments[0]
1055
+ if (!ts.isArrayLiteralExpression(arrayArg)) {
1056
+ return null
1057
+ }
1058
+
1059
+ const children: RpcStepMeta[] = []
1060
+
1061
+ for (const elem of arrayArg.elements) {
1062
+ if (ts.isCallExpression(elem) && isWorkflowDoCall(elem, context.checker)) {
1063
+ const step = extractRpcStep(elem, context)
1064
+ if (step) {
1065
+ children.push(step)
1066
+ }
1067
+ }
1068
+ }
1069
+
1070
+ if (children.length === 0) {
1071
+ return null
1072
+ }
1073
+
1074
+ return {
1075
+ type: 'parallel',
1076
+ children,
1077
+ }
1078
+ }
1079
+
1080
+ /**
1081
+ * Extract sequential fanout from for-of loop
1082
+ */
1083
+ function extractSequentialFanout(
1084
+ statement: ts.ForOfStatement,
1085
+ context: ExtractionContext
1086
+ ): FanoutStepMeta | null {
1087
+ if (!isSequentialFanout(statement)) {
1088
+ return null
1089
+ }
1090
+
1091
+ const vars = extractForOfVariable(statement)
1092
+ if (!vars) {
1093
+ return null
1094
+ }
1095
+
1096
+ const { itemVar, sourceVar } = vars
1097
+
1098
+ // Extract child step and optional sleep from loop body
1099
+ if (!ts.isBlock(statement.statement)) {
1100
+ return null
1101
+ }
1102
+
1103
+ let childStep: RpcStepMeta | null = null
1104
+ let timeBetween: string | undefined = undefined
1105
+
1106
+ // Create a child context with the loop variable added
1107
+ const childContext: ExtractionContext = {
1108
+ ...context,
1109
+ outputVars: new Map(context.outputVars),
1110
+ loopVars: new Set([...context.loopVars, itemVar]),
1111
+ }
1112
+
1113
+ let workflowDoCount = 0
1114
+
1115
+ for (const stmt of statement.statement.statements) {
1116
+ // Look for workflow.do in VariableStatement (const x = await workflow.do(...))
1117
+ if (ts.isVariableStatement(stmt)) {
1118
+ const declList = stmt.declarationList
1119
+ if (declList.declarations.length === 1) {
1120
+ const decl = declList.declarations[0]
1121
+ const init = decl.initializer
1122
+ if (
1123
+ init &&
1124
+ ts.isAwaitExpression(init) &&
1125
+ ts.isCallExpression(init.expression)
1126
+ ) {
1127
+ const call = init.expression
1128
+ if (isWorkflowDoCall(call, context.checker)) {
1129
+ workflowDoCount++
1130
+ const varName = ts.isIdentifier(decl.name)
1131
+ ? decl.name.text
1132
+ : undefined
1133
+ const step = extractRpcStep(call, childContext, varName)
1134
+ if (step) {
1135
+ childStep = step
1136
+ }
1137
+ }
1138
+ }
1139
+ }
1140
+ }
1141
+
1142
+ // Look for workflow.do in ExpressionStatement (await workflow.do(...))
1143
+ if (ts.isExpressionStatement(stmt)) {
1144
+ const expr = stmt.expression
1145
+
1146
+ if (ts.isAwaitExpression(expr) && ts.isCallExpression(expr.expression)) {
1147
+ const call = expr.expression
1148
+
1149
+ if (isWorkflowDoCall(call, context.checker)) {
1150
+ workflowDoCount++
1151
+ const step = extractRpcStep(call, childContext)
1152
+ if (step) {
1153
+ childStep = step
1154
+ }
1155
+ }
1156
+
1157
+ if (isWorkflowSleepCall(call, context.checker)) {
1158
+ // Extract duration for timeBetween
1159
+ const args = call.arguments
1160
+ if (args.length >= 2) {
1161
+ try {
1162
+ const numValue = extractNumberLiteral(args[1])
1163
+ if (numValue !== null) {
1164
+ timeBetween = `${numValue}ms`
1165
+ } else {
1166
+ timeBetween = extractStringLiteral(args[1], context.checker)
1167
+ }
1168
+ } catch {
1169
+ // Ignore extraction errors
1170
+ }
1171
+ }
1172
+ }
1173
+ }
1174
+ }
1175
+
1176
+ // Look for if statement with sleep
1177
+ if (ts.isIfStatement(stmt)) {
1178
+ if (ts.isBlock(stmt.thenStatement)) {
1179
+ for (const thenStmt of stmt.thenStatement.statements) {
1180
+ if (ts.isExpressionStatement(thenStmt)) {
1181
+ const expr = thenStmt.expression
1182
+
1183
+ if (
1184
+ ts.isAwaitExpression(expr) &&
1185
+ ts.isCallExpression(expr.expression)
1186
+ ) {
1187
+ const call = expr.expression
1188
+
1189
+ if (isWorkflowSleepCall(call, context.checker)) {
1190
+ const args = call.arguments
1191
+ if (args.length >= 2) {
1192
+ try {
1193
+ const numValue = extractNumberLiteral(args[1])
1194
+ if (numValue !== null) {
1195
+ timeBetween = `${numValue}ms`
1196
+ } else {
1197
+ timeBetween = extractStringLiteral(
1198
+ args[1],
1199
+ context.checker
1200
+ )
1201
+ }
1202
+ } catch {
1203
+ // Ignore extraction errors
1204
+ }
1205
+ }
1206
+ }
1207
+ }
1208
+ }
1209
+ }
1210
+ }
1211
+ }
1212
+ }
1213
+
1214
+ if (!childStep) {
1215
+ return null
1216
+ }
1217
+
1218
+ // If there are multiple workflow.do calls, the loop is too complex for DSL
1219
+ if (workflowDoCount > 1) {
1220
+ context.errors.push({
1221
+ message: `For-of loop has ${workflowDoCount} workflow.do calls but DSL only supports 1. Use pikkuWorkflowComplexFunc for complex loops.`,
1222
+ node: statement,
1223
+ })
1224
+ return null
1225
+ }
1226
+
1227
+ return {
1228
+ type: 'fanout',
1229
+ stepName: childStep.stepName,
1230
+ sourceVar,
1231
+ itemVar,
1232
+ mode: 'sequential',
1233
+ child: childStep,
1234
+ timeBetween,
1235
+ }
1236
+ }
1237
+
1238
+ /**
1239
+ * Extract a single output binding from an expression
1240
+ */
1241
+ function extractOutputBinding(
1242
+ expr: ts.Expression,
1243
+ context: ExtractionContext
1244
+ ): OutputBinding | null {
1245
+ // Check for property access (e.g., org.id, payment.status)
1246
+ if (ts.isPropertyAccessExpression(expr)) {
1247
+ const objName = ts.isIdentifier(expr.expression)
1248
+ ? expr.expression.text
1249
+ : null
1250
+ const propPath = expr.name.text
1251
+
1252
+ if (objName && context.outputVars.has(objName)) {
1253
+ return { from: 'outputVar', name: objName, path: propPath }
1254
+ }
1255
+ if (objName && context.contextVars.has(objName)) {
1256
+ return { from: 'stateVar', name: objName, path: propPath }
1257
+ }
1258
+ if (objName === context.inputParamName) {
1259
+ return { from: 'input', path: propPath }
1260
+ }
1261
+ }
1262
+
1263
+ // Check for identifier (simple variable reference)
1264
+ if (ts.isIdentifier(expr)) {
1265
+ const varName = expr.text
1266
+ if (context.outputVars.has(varName)) {
1267
+ return { from: 'outputVar', name: varName }
1268
+ }
1269
+ if (context.contextVars.has(varName)) {
1270
+ return { from: 'stateVar', name: varName }
1271
+ }
1272
+ if (varName === context.inputParamName) {
1273
+ return { from: 'input', path: varName }
1274
+ }
1275
+ }
1276
+
1277
+ // Check for literals
1278
+ if (ts.isStringLiteral(expr)) {
1279
+ return { from: 'literal', value: expr.text }
1280
+ }
1281
+ if (ts.isNumericLiteral(expr)) {
1282
+ return { from: 'literal', value: Number(expr.text) }
1283
+ }
1284
+ if (expr.kind === ts.SyntaxKind.TrueKeyword) {
1285
+ return { from: 'literal', value: true }
1286
+ }
1287
+ if (expr.kind === ts.SyntaxKind.FalseKeyword) {
1288
+ return { from: 'literal', value: false }
1289
+ }
1290
+ if (expr.kind === ts.SyntaxKind.NullKeyword) {
1291
+ return { from: 'literal', value: null }
1292
+ }
1293
+
1294
+ // For any other expression (comparisons, method calls, etc.), capture as expression
1295
+ return { from: 'expression', expression: getSourceText(expr) }
1296
+ }
1297
+
1298
+ /**
1299
+ * Extract return step
1300
+ */
1301
+ function extractReturn(
1302
+ statement: ts.ReturnStatement,
1303
+ context: ExtractionContext
1304
+ ): ReturnStepMeta | null {
1305
+ if (!statement.expression) {
1306
+ return null
1307
+ }
1308
+
1309
+ if (!ts.isObjectLiteralExpression(statement.expression)) {
1310
+ return null
1311
+ }
1312
+
1313
+ const outputs: Record<string, OutputBinding> = {}
1314
+
1315
+ for (const prop of statement.expression.properties) {
1316
+ if (
1317
+ ts.isPropertyAssignment(prop) ||
1318
+ ts.isShorthandPropertyAssignment(prop)
1319
+ ) {
1320
+ const propName = ts.isIdentifier(prop.name) ? prop.name.text : null
1321
+ if (!propName) {
1322
+ continue
1323
+ }
1324
+
1325
+ let binding: OutputBinding | null = null
1326
+
1327
+ if (ts.isShorthandPropertyAssignment(prop)) {
1328
+ // { orgId } - must be an output variable, context variable, or input
1329
+ const varName = prop.name.text
1330
+ if (context.outputVars.has(varName)) {
1331
+ binding = { from: 'outputVar', name: varName }
1332
+ } else if (context.contextVars.has(varName)) {
1333
+ binding = { from: 'stateVar', name: varName }
1334
+ } else {
1335
+ binding = { from: 'input', path: varName }
1336
+ }
1337
+ } else if (ts.isPropertyAssignment(prop)) {
1338
+ binding = extractOutputBinding(prop.initializer, context)
1339
+ }
1340
+
1341
+ if (binding) {
1342
+ outputs[propName] = binding
1343
+ }
1344
+ }
1345
+ }
1346
+
1347
+ if (Object.keys(outputs).length === 0) {
1348
+ return null
1349
+ }
1350
+
1351
+ return {
1352
+ type: 'return',
1353
+ outputs,
1354
+ }
1355
+ }
1356
+
1357
+ /**
1358
+ * Extract input sources from an argument node
1359
+ */
1360
+ function extractInputSources(
1361
+ node: ts.Node,
1362
+ context: ExtractionContext
1363
+ ): Record<string, InputSource> | 'passthrough' | undefined {
1364
+ // Handle when data is passed directly (e.g., workflow.do('step', 'rpc', data))
1365
+ if (ts.isIdentifier(node)) {
1366
+ if (node.text === context.inputParamName) {
1367
+ // The entire input data is being passed through
1368
+ return 'passthrough'
1369
+ }
1370
+ // Check if it's an output variable being passed directly
1371
+ if (context.outputVars.has(node.text)) {
1372
+ return 'passthrough'
1373
+ }
1374
+ }
1375
+
1376
+ if (!ts.isObjectLiteralExpression(node)) {
1377
+ return undefined
1378
+ }
1379
+
1380
+ const inputs: Record<string, InputSource> = {}
1381
+
1382
+ for (const prop of node.properties) {
1383
+ if (
1384
+ ts.isPropertyAssignment(prop) ||
1385
+ ts.isShorthandPropertyAssignment(prop)
1386
+ ) {
1387
+ const propName = ts.isIdentifier(prop.name) ? prop.name.text : null
1388
+ if (!propName) {
1389
+ continue
1390
+ }
1391
+
1392
+ let source: InputSource | null = null
1393
+
1394
+ if (ts.isShorthandPropertyAssignment(prop)) {
1395
+ // { email } - could be from loop var, output var, or input
1396
+ const varName = prop.name.text
1397
+ if (context.loopVars.has(varName)) {
1398
+ source = { from: 'item', path: varName }
1399
+ } else if (context.outputVars.has(varName)) {
1400
+ source = { from: 'outputVar', name: varName }
1401
+ } else {
1402
+ source = { from: 'input', path: varName }
1403
+ }
1404
+ } else if (ts.isPropertyAssignment(prop)) {
1405
+ source = extractInputSource(prop.initializer, context)
1406
+ }
1407
+
1408
+ if (source) {
1409
+ inputs[propName] = source
1410
+ }
1411
+ }
1412
+
1413
+ if (ts.isSpreadAssignment(prop)) {
1414
+ // Handle spread: { ...data }
1415
+ if (ts.isIdentifier(prop.expression)) {
1416
+ const varName = prop.expression.text
1417
+ if (varName === context.inputParamName) {
1418
+ // This is spreading the input data
1419
+ // We can't fully model this in v1, so we'll skip it
1420
+ continue
1421
+ }
1422
+ }
1423
+ }
1424
+ }
1425
+
1426
+ return Object.keys(inputs).length > 0 ? inputs : undefined
1427
+ }
1428
+
1429
+ /**
1430
+ * Extract a single input source
1431
+ */
1432
+ function extractInputSource(
1433
+ node: ts.Node,
1434
+ context: ExtractionContext
1435
+ ): InputSource | null {
1436
+ // Property access: data.email, org.id
1437
+ if (ts.isPropertyAccessExpression(node)) {
1438
+ const objExpr = node.expression
1439
+ const propName = node.name.text
1440
+
1441
+ if (ts.isIdentifier(objExpr)) {
1442
+ const objName = objExpr.text
1443
+
1444
+ if (objName === context.inputParamName) {
1445
+ return { from: 'input', path: propName }
1446
+ }
1447
+
1448
+ if (context.outputVars.has(objName)) {
1449
+ return { from: 'outputVar', name: objName, path: propName }
1450
+ }
1451
+ }
1452
+ }
1453
+
1454
+ // Identifier: email, orgId
1455
+ if (ts.isIdentifier(node)) {
1456
+ const varName = node.text
1457
+
1458
+ // Check if it's a loop variable (from fanout)
1459
+ if (context.loopVars.has(varName)) {
1460
+ return { from: 'item', path: varName }
1461
+ }
1462
+
1463
+ if (context.outputVars.has(varName)) {
1464
+ return { from: 'outputVar', name: varName }
1465
+ }
1466
+
1467
+ // Assume it's from input
1468
+ return { from: 'input', path: varName }
1469
+ }
1470
+
1471
+ // Literal: "string", 123, true, false, null
1472
+ if (
1473
+ ts.isStringLiteral(node) ||
1474
+ ts.isNumericLiteral(node) ||
1475
+ node.kind === ts.SyntaxKind.TrueKeyword ||
1476
+ node.kind === ts.SyntaxKind.FalseKeyword ||
1477
+ node.kind === ts.SyntaxKind.NullKeyword
1478
+ ) {
1479
+ let value: unknown
1480
+ if (ts.isStringLiteral(node)) {
1481
+ value = node.text
1482
+ } else if (ts.isNumericLiteral(node)) {
1483
+ value = Number(node.text)
1484
+ } else if (node.kind === ts.SyntaxKind.TrueKeyword) {
1485
+ value = true
1486
+ } else if (node.kind === ts.SyntaxKind.FalseKeyword) {
1487
+ value = false
1488
+ } else if (node.kind === ts.SyntaxKind.NullKeyword) {
1489
+ value = null
1490
+ }
1491
+ return { from: 'literal', value }
1492
+ }
1493
+
1494
+ // Object literal
1495
+ if (ts.isObjectLiteralExpression(node)) {
1496
+ const obj: Record<string, unknown> = {}
1497
+ for (const prop of node.properties) {
1498
+ if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) {
1499
+ const propName = prop.name.text
1500
+ const propSource = extractInputSource(prop.initializer, context)
1501
+ if (propSource && propSource.from === 'literal') {
1502
+ obj[propName] = propSource.value
1503
+ }
1504
+ }
1505
+ }
1506
+ return { from: 'literal', value: obj }
1507
+ }
1508
+
1509
+ // Array literal
1510
+ if (ts.isArrayLiteralExpression(node)) {
1511
+ const arr: unknown[] = []
1512
+ for (const elem of node.elements) {
1513
+ const elemSource = extractInputSource(elem, context)
1514
+ if (elemSource && elemSource.from === 'literal') {
1515
+ arr.push(elemSource.value)
1516
+ }
1517
+ }
1518
+ return { from: 'literal', value: arr }
1519
+ }
1520
+
1521
+ // No substitution template literal: `hello`
1522
+ if (ts.isNoSubstitutionTemplateLiteral(node)) {
1523
+ return { from: 'literal', value: node.text }
1524
+ }
1525
+
1526
+ // Template expression with substitutions: `hello ${name}`
1527
+ if (ts.isTemplateExpression(node)) {
1528
+ const parts: string[] = [node.head.text]
1529
+ const expressions: InputSource[] = []
1530
+
1531
+ for (const span of node.templateSpans) {
1532
+ // Extract each expression
1533
+ const exprSource = extractInputSource(span.expression, context)
1534
+ if (exprSource) {
1535
+ expressions.push(exprSource)
1536
+ } else {
1537
+ // Fallback: use source text as literal
1538
+ expressions.push({
1539
+ from: 'literal',
1540
+ value: getSourceText(span.expression),
1541
+ })
1542
+ }
1543
+ parts.push(span.literal.text)
1544
+ }
1545
+
1546
+ return { from: 'template', parts, expressions }
1547
+ }
1548
+
1549
+ return null
1550
+ }
1551
+
1552
+ /**
1553
+ * Extract a literal value from an expression
1554
+ */
1555
+ function extractLiteralValue(expr: ts.Expression): unknown | undefined {
1556
+ if (ts.isStringLiteral(expr)) {
1557
+ return expr.text
1558
+ }
1559
+ if (ts.isNumericLiteral(expr)) {
1560
+ return Number(expr.text)
1561
+ }
1562
+ if (expr.kind === ts.SyntaxKind.TrueKeyword) {
1563
+ return true
1564
+ }
1565
+ if (expr.kind === ts.SyntaxKind.FalseKeyword) {
1566
+ return false
1567
+ }
1568
+ if (expr.kind === ts.SyntaxKind.NullKeyword) {
1569
+ return null
1570
+ }
1571
+ // Array literal
1572
+ if (ts.isArrayLiteralExpression(expr)) {
1573
+ const values: unknown[] = []
1574
+ for (const el of expr.elements) {
1575
+ const v = extractLiteralValue(el)
1576
+ if (v === undefined) return undefined
1577
+ values.push(v)
1578
+ }
1579
+ return values
1580
+ }
1581
+ // Object literal (simple keys with literal values)
1582
+ if (ts.isObjectLiteralExpression(expr)) {
1583
+ const obj: Record<string, unknown> = {}
1584
+ for (const prop of expr.properties) {
1585
+ if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) {
1586
+ const v = extractLiteralValue(prop.initializer)
1587
+ if (v === undefined) return undefined
1588
+ obj[prop.name.text] = v
1589
+ } else {
1590
+ return undefined
1591
+ }
1592
+ }
1593
+ return obj
1594
+ }
1595
+ return undefined
1596
+ }
1597
+
1598
+ /**
1599
+ * Infer a simple type string from a TypeScript type
1600
+ */
1601
+ function inferSimpleType(type: ts.Type, checker: ts.TypeChecker): string {
1602
+ const typeStr = checker.typeToString(type)
1603
+ if (typeStr === 'string') return 'string'
1604
+ if (typeStr === 'number') return 'number'
1605
+ if (typeStr === 'boolean') return 'boolean'
1606
+ if (typeStr.endsWith('[]') || typeStr.startsWith('Array<')) return 'array'
1607
+ return 'object'
1608
+ }