@pikku/inspector 0.12.46 → 0.12.48

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 (51) hide show
  1. package/CHANGELOG.md +197 -0
  2. package/README.md +28 -2
  3. package/dist/add/add-feature.d.ts +19 -0
  4. package/dist/add/add-feature.js +200 -0
  5. package/dist/add/add-functions.js +24 -9
  6. package/dist/add/add-workflow.js +22 -29
  7. package/dist/error-codes.d.ts +3 -0
  8. package/dist/error-codes.js +3 -0
  9. package/dist/index.d.ts +1 -0
  10. package/dist/inspector.js +39 -2
  11. package/dist/types.d.ts +43 -0
  12. package/dist/utils/extract-function-name.js +27 -0
  13. package/dist/utils/post-process.d.ts +26 -0
  14. package/dist/utils/post-process.js +98 -0
  15. package/dist/utils/serialize-inspector-state.d.ts +2 -1
  16. package/dist/utils/serialize-inspector-state.js +2 -0
  17. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +203 -19
  18. package/dist/utils/workflow/dsl/patterns.d.ts +21 -0
  19. package/dist/utils/workflow/dsl/patterns.js +30 -0
  20. package/dist/utils/workflow/dsl/validation.d.ts +5 -0
  21. package/dist/utils/workflow/dsl/validation.js +9 -0
  22. package/dist/utils/workflow/graph/convert-dsl-to-graph.js +36 -0
  23. package/dist/utils/workflow/graph/workflow-graph.types.d.ts +5 -1
  24. package/dist/visit.js +2 -0
  25. package/package.json +4 -3
  26. package/src/add/add-feature.test.ts +306 -0
  27. package/src/add/add-feature.ts +253 -0
  28. package/src/add/add-functions.ts +29 -9
  29. package/src/add/add-scenario-step.test.ts +311 -0
  30. package/src/add/add-workflow-silent-drops.test.ts +98 -0
  31. package/src/add/add-workflow.ts +25 -37
  32. package/src/add/scenario-not-extractable.test.ts +159 -0
  33. package/src/add/scenario-services.test.ts +103 -0
  34. package/src/add/scenario-skip.test.ts +83 -0
  35. package/src/error-codes.ts +3 -0
  36. package/src/index.ts +1 -0
  37. package/src/inspector.ts +50 -1
  38. package/src/tsconfig-paths.test.ts +90 -0
  39. package/src/types.ts +45 -0
  40. package/src/utils/extract-function-name.ts +36 -0
  41. package/src/utils/post-process.test.ts +36 -2
  42. package/src/utils/post-process.ts +111 -0
  43. package/src/utils/serialize-inspector-state.ts +8 -1
  44. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +236 -22
  45. package/src/utils/workflow/dsl/patterns.ts +38 -0
  46. package/src/utils/workflow/dsl/validation.ts +12 -0
  47. package/src/utils/workflow/graph/convert-dsl-to-graph-skip.test.ts +35 -0
  48. package/src/utils/workflow/graph/convert-dsl-to-graph.ts +36 -0
  49. package/src/utils/workflow/graph/workflow-graph.types.ts +5 -0
  50. package/src/visit.ts +2 -0
  51. package/tsconfig.tsbuildinfo +1 -1
@@ -16,6 +16,8 @@ import { extractTypeKeys } from './type-utils.js'
16
16
  import { ErrorCode } from '../error-codes.js'
17
17
  import { AUTH_HANDLER_FUNC_ID } from '../add/add-auth.js'
18
18
  import { flattenScopeDefinitions } from '@pikku/core/scope'
19
+ import type { WorkflowStepMeta } from '@pikku/core/workflow'
20
+ import { DYNAMIC_SCENARIO_STEP_TARGET } from './workflow/dsl/patterns.js'
19
21
 
20
22
  /**
21
23
  * Stamp the inspected authorize/callbacks service set onto the generated auth
@@ -154,6 +156,20 @@ export function aggregateRequiredServices(
154
156
  }
155
157
  })
156
158
 
159
+ // 1b. Services the auth factory touches, read from the definition rather than
160
+ // from the generated handler.
161
+ //
162
+ // `stampAuthHandlerServices` copies them onto that handler's meta, but on the
163
+ // first run in a clean checkout the handler's file has not been written yet,
164
+ // so there is nothing to stamp and nothing to aggregate — and the services map
165
+ // comes out claiming `kysely` (or whatever else `authorize` reads) is unused.
166
+ // The second run, with the file on disk, says the opposite. The definition is
167
+ // inspected from hand-written source either way, so taking the answer from
168
+ // there makes a clean build agree with an incremental one.
169
+ if (state.auth?.definition) {
170
+ addServices(state.auth.definition.services)
171
+ }
172
+
157
173
  // 2. Services from used middleware (individual + groups)
158
174
  usedMiddleware.forEach((middlewareName) => {
159
175
  const middlewareMeta = state.middleware.definitions[middlewareName]
@@ -682,6 +698,101 @@ export function validateAgentModels(
682
698
  }
683
699
  }
684
700
 
701
+ /**
702
+ * Scenarios are pure stories of remote RPCs (same rule as client-side CLI
703
+ * renderers): the func may only destructure logger/config — everything else
704
+ * must go through actor steps so the flow runs against the TARGET
705
+ * environment, never local services.
706
+ *
707
+ * This runs in post-processing rather than in `addWorkflow` because
708
+ * `addWorkflow` runs in the `visitSetup` pass, which completes before
709
+ * `addFunctions` (`visitFunctions`) has populated `state.functions.meta` — so
710
+ * the check read `undefined` there and never fired.
711
+ */
712
+ export function validateScenarioServices(
713
+ logger: InspectorLogger,
714
+ state: InspectorState | Omit<InspectorState, 'typesLookup'>
715
+ ): void {
716
+ for (const [workflowName, meta] of Object.entries(state.workflows.meta)) {
717
+ if (!meta.scenario) continue
718
+ const funcMeta = state.functions.meta[meta.pikkuFuncId]
719
+ if (!funcMeta?.services) continue
720
+ const disallowed = funcMeta.services.services.filter(
721
+ (svc) => svc !== 'logger' && svc !== 'config'
722
+ )
723
+ if (disallowed.length > 0) {
724
+ logger.critical(
725
+ ErrorCode.SCENARIO_HAS_SERVICES,
726
+ `Scenario '${workflowName}' destructures services: ${disallowed.join(', ')}. ` +
727
+ `Scenarios may only use 'logger'/'config' — drive everything else through ` +
728
+ `actor steps (workflow.do(step, rpc, data, { actor: actors.x })) so the flow ` +
729
+ `runs against the target environment.`
730
+ )
731
+ }
732
+ }
733
+ }
734
+
735
+ /**
736
+ * Walk every scenario step, wherever it is nested, and validate the two things
737
+ * that can only be known once both the workflow meta and the function meta
738
+ * exist:
739
+ *
740
+ * - the step target must be a static string literal (PKU678) — otherwise it
741
+ * can't be bundled, typed or drawn;
742
+ * - a `browser: true` step must be given an actor (PKU677), since the browser
743
+ * context signs in as that persona.
744
+ *
745
+ * It also marks the step's function as used, so `scenario run` boots the
746
+ * singletons the step actually needs.
747
+ */
748
+ export function validateScenarioSteps(
749
+ logger: InspectorLogger,
750
+ state: InspectorState | Omit<InspectorState, 'typesLookup'>
751
+ ): void {
752
+ const visit = (workflowName: string, steps: WorkflowStepMeta[]): void => {
753
+ for (const step of steps) {
754
+ if (step.type === 'scenarioStep') {
755
+ if (step.stepFunc === DYNAMIC_SCENARIO_STEP_TARGET) {
756
+ logger.critical(
757
+ ErrorCode.SCENARIO_STEP_TARGET_NOT_STATIC,
758
+ `Scenario '${workflowName}' calls step '${step.stepName}' with a target that isn't a string literal. ` +
759
+ `A step target must be statically resolvable so it can be bundled, typed and drawn — ` +
760
+ `loop over data sets instead of computing the step name.`
761
+ )
762
+ continue
763
+ }
764
+ state.serviceAggregation.usedFunctions.add(step.stepFunc)
765
+ const stepMeta = state.functions.meta[step.stepFunc]
766
+ if (stepMeta?.scenarioStepBrowser && !step.actor) {
767
+ logger.critical(
768
+ ErrorCode.SCENARIO_BROWSER_STEP_NEEDS_ACTOR,
769
+ `Scenario '${workflowName}' calls browser step '${step.stepFunc}' without an actor. ` +
770
+ `Pass { actor: actors.<name> } so the browser context signs in as that persona.`
771
+ )
772
+ }
773
+ continue
774
+ }
775
+ if (step.type === 'branch') {
776
+ for (const branch of step.branches) visit(workflowName, branch.steps)
777
+ if (step.elseSteps) visit(workflowName, step.elseSteps)
778
+ } else if (step.type === 'switch') {
779
+ for (const c of step.cases ?? []) {
780
+ if (c.steps) visit(workflowName, c.steps)
781
+ }
782
+ if (step.defaultSteps) visit(workflowName, step.defaultSteps)
783
+ } else if (step.type === 'fanout' && step.body) {
784
+ visit(workflowName, step.body as WorkflowStepMeta[])
785
+ } else if (step.type === 'parallel' && step.children) {
786
+ visit(workflowName, step.children as WorkflowStepMeta[])
787
+ }
788
+ }
789
+ }
790
+
791
+ for (const [workflowName, meta] of Object.entries(state.workflows.meta)) {
792
+ visit(workflowName, meta.steps ?? [])
793
+ }
794
+ }
795
+
685
796
  /**
686
797
  * A `pikkuWorkflowGraph` node that references the `graph:` namespace (e.g.
687
798
  * `graph:editFields`) needs @pikku/addon-graph wired — otherwise the RPC never
@@ -1,5 +1,9 @@
1
1
  import type { JSONValue } from '@pikku/core'
2
- import type { InspectorDiagnostic, InspectorState } from '../types.js'
2
+ import type {
3
+ InspectorDiagnostic,
4
+ InspectorFeature,
5
+ InspectorState,
6
+ } from '../types.js'
3
7
  import { TypesMap } from '../types-map.js'
4
8
 
5
9
  /**
@@ -139,6 +143,7 @@ export interface SerializableInspectorState {
139
143
  files: Array<[string, { path: string; exportedName: string }]>
140
144
  graphMeta: InspectorState['workflows']['graphMeta']
141
145
  graphFiles: Array<[string, { path: string; exportedName: string }]>
146
+ featureFiles: Array<[string, InspectorFeature]>
142
147
  invokedWorkflows: string[]
143
148
  }
144
149
  rpc: {
@@ -362,6 +367,7 @@ export function serializeInspectorState(
362
367
  files: Array.from(state.workflows.files.entries()),
363
368
  graphMeta: state.workflows.graphMeta,
364
369
  graphFiles: Array.from(state.workflows.graphFiles.entries()),
370
+ featureFiles: Array.from(state.workflows.featureFiles.entries()),
365
371
  invokedWorkflows: Array.from(state.workflows.invokedWorkflows),
366
372
  },
367
373
  rpc: {
@@ -558,6 +564,7 @@ export function deserializeInspectorState(
558
564
  files: new Map(data.workflows.files),
559
565
  graphMeta: data.workflows.graphMeta || {},
560
566
  graphFiles: new Map(data.workflows.graphFiles || []),
567
+ featureFiles: new Map(data.workflows.featureFiles || []),
561
568
  invokedWorkflows: new Set(data.workflows.invokedWorkflows || []),
562
569
  },
563
570
  rpc: {
@@ -15,6 +15,7 @@ import type {
15
15
  SwitchCaseMeta,
16
16
  FilterStepMeta,
17
17
  ArrayPredicateStepMeta,
18
+ ScenarioStepMeta,
18
19
  InputSource,
19
20
  OutputBinding,
20
21
  Condition,
@@ -24,6 +25,9 @@ import type {
24
25
  import {
25
26
  isWorkflowDoCall,
26
27
  isWorkflowExpectEventuallyCall,
28
+ isScenarioStepCall,
29
+ getScenarioStepPhase,
30
+ DYNAMIC_SCENARIO_STEP_TARGET,
27
31
  extractActorFromOptions,
28
32
  isWorkflowSleepCall,
29
33
  isWorkflowSuspendCall,
@@ -60,7 +64,7 @@ interface ExtractionContext {
60
64
  outputVars: Map<string, { type: ts.Type; node: ts.Node }>
61
65
  arrayVars: Set<string>
62
66
  conditionalVars: Set<string>
63
- inputParamName: string
67
+ inputParamName: string | null
64
68
  errors: ValidationError[]
65
69
  /** Loop variables in scope (for fanout item variables) */
66
70
  loopVars: Set<string>
@@ -101,7 +105,7 @@ export function extractDSLWorkflow(
101
105
 
102
106
  // Extract input parameter name (second parameter)
103
107
  const inputParamName = extractInputParamName(arrowFunc)
104
- if (!inputParamName) {
108
+ if (inputParamName === undefined) {
105
109
  return {
106
110
  status: 'error',
107
111
  reason: 'Could not determine input parameter name',
@@ -220,9 +224,17 @@ function findWorkflowFunction(node: ts.Node): ts.ArrowFunction | null {
220
224
  }
221
225
 
222
226
  /**
223
- * Extract the input parameter name from the arrow function
227
+ * Extract the input parameter name from the arrow function.
228
+ *
229
+ * Returns `null` when no input parameter is declared at all. That is
230
+ * legitimate — a workflow may ignore its input — and nothing in the body can
231
+ * reference it, so every name comparison against `null` correctly fails.
232
+ * Returns `undefined` when an input parameter is declared but is not a plain
233
+ * identifier (destructured), which the extractor cannot track by name.
224
234
  */
225
- function extractInputParamName(arrowFunc: ts.ArrowFunction): string | null {
235
+ function extractInputParamName(
236
+ arrowFunc: ts.ArrowFunction
237
+ ): string | null | undefined {
226
238
  if (arrowFunc.parameters.length < 2) {
227
239
  return null
228
240
  }
@@ -232,7 +244,7 @@ function extractInputParamName(arrowFunc: ts.ArrowFunction): string | null {
232
244
  return secondParam.name.text
233
245
  }
234
246
 
235
- return null
247
+ return undefined
236
248
  }
237
249
 
238
250
  /**
@@ -298,7 +310,34 @@ function extractStep(
298
310
 
299
311
  // For-of statement (sequential fanout)
300
312
  if (ts.isForOfStatement(statement)) {
301
- return extractSequentialFanout(statement, context)
313
+ const reason: { message?: string } = {}
314
+ const fanout = extractSequentialFanout(statement, context, reason)
315
+ if (!fanout && containsWorkflowCall(statement.statement, context.checker)) {
316
+ // A for-of the DSL can't model as a sequential fanout (its iterable is not
317
+ // a data-array identifier/field — e.g. a counting loop
318
+ // `for (const i of [...Array(N).keys()])`). Silently returning null here
319
+ // used to drop the loop AND every `workflow.do` inside it, so the workflow
320
+ // serialized with zero steps and its step functions were never registered
321
+ // (→ runtime "Function not found"). Fail loudly instead: pikkuWorkflowFunc
322
+ // reports INVALID_DSL_WORKFLOW; pikkuWorkflowComplexFunc falls back to the
323
+ // basic AST walk (which DOES register loop-invoked functions), so a genuine
324
+ // control-flow loop belongs in a complex workflow.
325
+ //
326
+ // Guarded on the body actually containing a workflow call: a loop that only
327
+ // massages locals (building a lookup, summing a total) has no step to lose,
328
+ // and erroring on it would be a false positive — the whole hazard is a
329
+ // dropped `workflow.do`, not a dropped `for`.
330
+ context.errors.push({
331
+ message:
332
+ `The for-of loop '${statement.getText().slice(0, 60)}' can't be expressed in a DSL workflow — ` +
333
+ (reason.message ??
334
+ `its iterable must be a data array (an identifier or field like 'data.items'), not a computed/inline ` +
335
+ `iterable such as '[...Array(n).keys()]'. Use pikkuWorkflowComplexFunc for a control-flow/counting loop, ` +
336
+ `or iterate over a real input array.`),
337
+ node: statement,
338
+ })
339
+ }
340
+ return fanout
302
341
  }
303
342
 
304
343
  // Return statement
@@ -363,10 +402,12 @@ function extractVariableDeclaration(
363
402
  // Check for await workflow.do(...)
364
403
  if (ts.isAwaitExpression(init) && ts.isCallExpression(init.expression)) {
365
404
  const call = init.expression
366
- if (isWorkflowDoCall(call, context.checker)) {
367
- const step = isInlineDoCall(call)
368
- ? extractInlineStep(call, context)
369
- : extractRpcStep(call, context, varName)
405
+ if (isWorkflowDoCall(call, context.checker) || isScenarioStepCall(call)) {
406
+ const step = isScenarioStepCall(call)
407
+ ? extractScenarioStep(call, context, varName)
408
+ : isInlineDoCall(call)
409
+ ? extractInlineStep(call, context)
410
+ : extractRpcStep(call, context, varName)
370
411
  if (step) {
371
412
  // Track output variable
372
413
  const type = context.checker.getTypeAtLocation(decl)
@@ -515,6 +556,7 @@ function extractDestructuredDeclaration(
515
556
  const destructuresAStep =
516
557
  stepCall !== null &&
517
558
  (isWorkflowDoCall(stepCall, context.checker) ||
559
+ isScenarioStepCall(stepCall) ||
518
560
  isParallelGroup(stepCall) ||
519
561
  isParallelFanout(stepCall))
520
562
  if (!destructuresAStep) return null
@@ -584,10 +626,12 @@ function extractExpressionStatement(
584
626
  if (ts.isAwaitExpression(expr) && ts.isCallExpression(expr.expression)) {
585
627
  const call = expr.expression
586
628
 
587
- if (isWorkflowDoCall(call, context.checker)) {
588
- const step = isInlineDoCall(call)
589
- ? extractInlineStep(call, context)
590
- : extractRpcStep(call, context, outputVar)
629
+ if (isWorkflowDoCall(call, context.checker) || isScenarioStepCall(call)) {
630
+ const step = isScenarioStepCall(call)
631
+ ? extractScenarioStep(call, context, outputVar)
632
+ : isInlineDoCall(call)
633
+ ? extractInlineStep(call, context)
634
+ : extractRpcStep(call, context, outputVar)
591
635
 
592
636
  // Track output variable if this is an assignment
593
637
  if (outputVar && step) {
@@ -692,6 +736,68 @@ function extractRpcStep(
692
736
  }
693
737
  }
694
738
 
739
+ /**
740
+ * Extract a scenario step from scenario.step/given/when/then(stepName,
741
+ * stepFunc, data?, options?).
742
+ *
743
+ * The step target must be a static string literal so it can be bundled, typed
744
+ * and drawn. When it isn't, the step is still recorded — with a `<dynamic>`
745
+ * target — so post-processing can raise PKU678 rather than the call silently
746
+ * vanishing from the graph.
747
+ */
748
+ function extractScenarioStep(
749
+ call: ts.CallExpression,
750
+ context: ExtractionContext,
751
+ outputVar?: string
752
+ ): ScenarioStepMeta | null {
753
+ const phase = getScenarioStepPhase(call)
754
+ if (!phase) {
755
+ return null
756
+ }
757
+
758
+ const args = call.arguments
759
+ if (args.length < 2) {
760
+ return null
761
+ }
762
+
763
+ try {
764
+ const stepName = extractStringLiteral(args[0], context.checker)
765
+
766
+ let stepFunc = DYNAMIC_SCENARIO_STEP_TARGET
767
+ try {
768
+ stepFunc = extractStringLiteral(args[1], context.checker)
769
+ } catch {
770
+ // Left as '<dynamic>' — validated in post-processing (PKU678).
771
+ }
772
+
773
+ const inputs =
774
+ args.length >= 3 ? extractInputSources(args[2], context) : undefined
775
+
776
+ const optionsArg = args.length >= 4 ? args[3] : undefined
777
+ const options =
778
+ optionsArg && ts.isObjectLiteralExpression(optionsArg)
779
+ ? extractStepOptions(optionsArg, context)
780
+ : undefined
781
+
782
+ return {
783
+ type: 'scenarioStep',
784
+ stepName,
785
+ stepFunc,
786
+ phase,
787
+ outputVar,
788
+ inputs,
789
+ options,
790
+ actor: extractActorFromOptions(optionsArg),
791
+ }
792
+ } catch (error) {
793
+ context.errors.push({
794
+ message: `Failed to extract scenario step: ${error instanceof Error ? error.message : String(error)}`,
795
+ node: call,
796
+ })
797
+ return null
798
+ }
799
+ }
800
+
695
801
  /**
696
802
  * Extract inline step from workflow.do() call with a function argument
697
803
  */
@@ -1225,7 +1331,7 @@ function extractArrayPredicate(
1225
1331
  function extractFanoutBodyStep(
1226
1332
  stmt: ts.Statement,
1227
1333
  childContext: ExtractionContext,
1228
- body: Array<RpcStepMeta | SleepStepMeta | SuspendStepMeta>
1334
+ body: Array<RpcStepMeta | SleepStepMeta | SuspendStepMeta | ScenarioStepMeta>
1229
1335
  ): void {
1230
1336
  if (ts.isVariableStatement(stmt)) {
1231
1337
  const declList = stmt.declarationList
@@ -1242,11 +1348,16 @@ function extractFanoutBodyStep(
1242
1348
  return
1243
1349
  }
1244
1350
  const call = init.expression
1245
- if (!isWorkflowDoCall(call, childContext.checker)) {
1351
+ if (
1352
+ !isWorkflowDoCall(call, childContext.checker) &&
1353
+ !isScenarioStepCall(call)
1354
+ ) {
1246
1355
  return
1247
1356
  }
1248
1357
  const varName = ts.isIdentifier(decl.name) ? decl.name.text : undefined
1249
- const step = extractRpcStep(call, childContext, varName)
1358
+ const step = isScenarioStepCall(call)
1359
+ ? extractScenarioStep(call, childContext, varName)
1360
+ : extractRpcStep(call, childContext, varName)
1250
1361
  if (!step) {
1251
1362
  return
1252
1363
  }
@@ -1290,6 +1401,13 @@ function extractFanoutBodyStep(
1290
1401
  }
1291
1402
  return
1292
1403
  }
1404
+ if (isScenarioStepCall(call)) {
1405
+ const scenarioStep = extractScenarioStep(call, childContext)
1406
+ if (scenarioStep) {
1407
+ body.push(scenarioStep)
1408
+ }
1409
+ return
1410
+ }
1293
1411
  if (!isWorkflowDoCall(call, childContext.checker)) {
1294
1412
  return
1295
1413
  }
@@ -1363,7 +1481,12 @@ function extractParallelFanout(
1363
1481
  ? expr
1364
1482
  : null
1365
1483
 
1366
- if (doCall && isWorkflowDoCall(doCall, context.checker)) {
1484
+ if (doCall && isScenarioStepCall(doCall)) {
1485
+ const step = extractScenarioStep(doCall, childContext)
1486
+ if (step) {
1487
+ body.push(step)
1488
+ }
1489
+ } else if (doCall && isWorkflowDoCall(doCall, context.checker)) {
1367
1490
  const step = extractRpcStep(doCall, childContext)
1368
1491
  if (step) {
1369
1492
  body.push(step)
@@ -1396,10 +1519,18 @@ function extractParallelGroup(
1396
1519
  return null
1397
1520
  }
1398
1521
 
1399
- const children: RpcStepMeta[] = []
1522
+ const children: Array<RpcStepMeta | ScenarioStepMeta> = []
1400
1523
 
1401
1524
  for (const elem of arrayArg.elements) {
1402
- if (ts.isCallExpression(elem) && isWorkflowDoCall(elem, context.checker)) {
1525
+ if (!ts.isCallExpression(elem)) {
1526
+ continue
1527
+ }
1528
+ if (isScenarioStepCall(elem)) {
1529
+ const step = extractScenarioStep(elem, context)
1530
+ if (step) {
1531
+ children.push(step)
1532
+ }
1533
+ } else if (isWorkflowDoCall(elem, context.checker)) {
1403
1534
  const step = extractRpcStep(elem, context)
1404
1535
  if (step) {
1405
1536
  children.push(step)
@@ -1420,9 +1551,40 @@ function extractParallelGroup(
1420
1551
  /**
1421
1552
  * Extract sequential fanout from for-of loop
1422
1553
  */
1554
+ /**
1555
+ * Does this subtree call the workflow wire at all?
1556
+ *
1557
+ * Used to tell "the DSL can't model this loop, and a step would be silently
1558
+ * lost" from "the DSL can't model this loop, and there is nothing in it to
1559
+ * lose". Only the first is worth failing a build over.
1560
+ */
1561
+ function containsWorkflowCall(node: ts.Node, checker: ts.TypeChecker): boolean {
1562
+ let found = false
1563
+ const visit = (child: ts.Node): void => {
1564
+ if (found) return
1565
+ if (ts.isCallExpression(child)) {
1566
+ if (
1567
+ isWorkflowDoCall(child, checker) ||
1568
+ isScenarioStepCall(child) ||
1569
+ isWorkflowSleepCall(child, checker) ||
1570
+ isWorkflowSuspendCall(child, checker) ||
1571
+ isWorkflowApprovalCall(child, checker) ||
1572
+ isWorkflowExpectEventuallyCall(child)
1573
+ ) {
1574
+ found = true
1575
+ return
1576
+ }
1577
+ }
1578
+ ts.forEachChild(child, visit)
1579
+ }
1580
+ visit(node)
1581
+ return found
1582
+ }
1583
+
1423
1584
  function extractSequentialFanout(
1424
1585
  statement: ts.ForOfStatement,
1425
- context: ExtractionContext
1586
+ context: ExtractionContext,
1587
+ reason?: { message?: string }
1426
1588
  ): FanoutStepMeta | null {
1427
1589
  if (!isSequentialFanout(statement)) {
1428
1590
  return null
@@ -1521,6 +1683,18 @@ function extractSequentialFanout(
1521
1683
  }
1522
1684
 
1523
1685
  if (body.length === 0) {
1686
+ // The iterable was fine — the body yielded nothing the DSL can model. The
1687
+ // usual cause is a `workflow.do` nested inside an `if`/`switch`, because
1688
+ // `FanoutStepMeta['body']` is a flat list of steps with no branch member:
1689
+ // the DSL has no way to say "conditionally run this step per item".
1690
+ const branching = bodyStatements.some(
1691
+ (stmt) => ts.isIfStatement(stmt) || ts.isSwitchStatement(stmt)
1692
+ )
1693
+ if (reason) {
1694
+ reason.message = branching
1695
+ ? `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.`
1696
+ : `its body contains no 'workflow.do' call the DSL can model. Use pikkuWorkflowComplexFunc for a loop that does other work.`
1697
+ }
1524
1698
  return null
1525
1699
  }
1526
1700
 
@@ -1741,7 +1915,11 @@ function extractInputSources(
1741
1915
  } else if (context.outputVars.has(varName)) {
1742
1916
  source = { from: 'outputVar', name: varName }
1743
1917
  } else {
1744
- source = { from: 'input', path: varName }
1918
+ const constant = resolveConstantValue(prop.name, context.checker)
1919
+ source =
1920
+ constant !== undefined
1921
+ ? { from: 'literal', value: constant }
1922
+ : { from: 'input', path: varName }
1745
1923
  }
1746
1924
  } else if (ts.isPropertyAssignment(prop)) {
1747
1925
  source = extractInputSource(prop.initializer, context)
@@ -1831,6 +2009,11 @@ function extractInputSource(
1831
2009
  return { from: 'outputVar', name: varName }
1832
2010
  }
1833
2011
 
2012
+ const constant = resolveConstantValue(node, context.checker)
2013
+ if (constant !== undefined) {
2014
+ return { from: 'literal', value: constant }
2015
+ }
2016
+
1834
2017
  // Assume it's from input
1835
2018
  return { from: 'input', path: varName }
1836
2019
  }
@@ -1916,6 +2099,37 @@ function extractInputSource(
1916
2099
  return null
1917
2100
  }
1918
2101
 
2102
+ /**
2103
+ * Resolve an identifier that names a constant to the value it was declared with.
2104
+ *
2105
+ * Without this, a name the extractor does not recognise falls through to
2106
+ * `{ from: 'input' }` and is serialized as `{ $ref: 'trigger', path: <name> }` —
2107
+ * so a module-level `const RESOURCE_ID = 'x'` used as step input silently
2108
+ * becomes a read of a trigger field that does not exist. Only `const`
2109
+ * declarations with a fully literal initializer resolve; anything else (a name
2110
+ * bound out of the trigger, a computed value) falls through unchanged.
2111
+ */
2112
+ function resolveConstantValue(
2113
+ node: ts.Identifier,
2114
+ checker: ts.TypeChecker
2115
+ ): unknown | undefined {
2116
+ const symbol = checker.getSymbolAtLocation(node)
2117
+ const declaration = symbol?.valueDeclaration ?? symbol?.declarations?.[0]
2118
+ if (!declaration || !ts.isVariableDeclaration(declaration)) {
2119
+ return undefined
2120
+ }
2121
+ const declarationList = declaration.parent
2122
+ if (
2123
+ !ts.isVariableDeclarationList(declarationList) ||
2124
+ !(declarationList.flags & ts.NodeFlags.Const)
2125
+ ) {
2126
+ return undefined
2127
+ }
2128
+ return declaration.initializer
2129
+ ? extractLiteralValue(declaration.initializer)
2130
+ : undefined
2131
+ }
2132
+
1919
2133
  /**
1920
2134
  * Extract a literal value from an expression
1921
2135
  */
@@ -36,6 +36,44 @@ export function isWorkflowDoCall(
36
36
  )
37
37
  }
38
38
 
39
+ /**
40
+ * Placeholder recorded when a scenario step's target isn't a static string
41
+ * literal. The step is kept rather than dropped so post-processing can raise
42
+ * PKU678 against it — a dropped step is the silent-failure mode this repo has
43
+ * hit repeatedly on workflow graph round-trips.
44
+ */
45
+ export const DYNAMIC_SCENARIO_STEP_TARGET = '<dynamic>'
46
+
47
+ const SCENARIO_STEP_PHASES = ['step', 'given', 'when', 'then'] as const
48
+
49
+ export type ScenarioStepPhaseName = (typeof SCENARIO_STEP_PHASES)[number]
50
+
51
+ /**
52
+ * Check if a call expression is scenario.step()/given()/when()/then() — a
53
+ * declared `pikkuScenarioStep` invoked by name. `given`/`when`/`then` are pure
54
+ * sugar over `step`: the phase only changes the prose a reporter renders.
55
+ */
56
+ export function isScenarioStepCall(node: ts.CallExpression): boolean {
57
+ return getScenarioStepPhase(node) !== undefined
58
+ }
59
+
60
+ /**
61
+ * The Gherkin-style phase of a scenario step call, or undefined when the call
62
+ * isn't one.
63
+ */
64
+ export function getScenarioStepPhase(
65
+ node: ts.CallExpression
66
+ ): ScenarioStepPhaseName | undefined {
67
+ if (!ts.isPropertyAccessExpression(node.expression)) {
68
+ return
69
+ }
70
+ const propAccess = node.expression
71
+ if (!isWorkflowWireIdentifier(propAccess.expression)) {
72
+ return
73
+ }
74
+ return SCENARIO_STEP_PHASES.find((phase) => phase === propAccess.name.text)
75
+ }
76
+
39
77
  /**
40
78
  * Check if a call expression is workflow.expectEventually()
41
79
  */
@@ -22,6 +22,11 @@ export interface ValidationError {
22
22
  * - ReturnStatement
23
23
  * - ThrowStatement (for WorkflowCancelledException)
24
24
  * - Block (containers)
25
+ *
26
+ * When `allowInline` is set (complex workflows and scenarios), the bodies of
27
+ * closures are skipped: a callback is ordinary TypeScript, not DSL, so holding
28
+ * it to the statement whitelist would reject valid code. Plain DSL workflows
29
+ * still descend into callbacks, which is what validates fanout bodies.
25
30
  */
26
31
  export function validateNoDisallowedPatterns(
27
32
  node: ts.Node,
@@ -54,6 +59,13 @@ export function validateNoDisallowedPatterns(
54
59
  }
55
60
 
56
61
  function visitNode(node: ts.Node) {
62
+ if (
63
+ options?.allowInline &&
64
+ (ts.isArrowFunction(node) || ts.isFunctionExpression(node))
65
+ ) {
66
+ return
67
+ }
68
+
57
69
  // Disallow while and do-while
58
70
  if (ts.isWhileStatement(node) || ts.isDoStatement(node)) {
59
71
  errors.push({
@@ -0,0 +1,35 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert'
3
+ import { convertDslToGraph } from './convert-dsl-to-graph.js'
4
+ import type { WorkflowsMeta } from '@pikku/core'
5
+
6
+ const scenarioMeta = (
7
+ overrides: Partial<WorkflowsMeta[string]> = {}
8
+ ): WorkflowsMeta[string] => ({
9
+ pikkuFuncId: 'installAddonFreshNameScenario',
10
+ name: 'installAddonFreshNameScenario',
11
+ scenario: true,
12
+ steps: [],
13
+ ...overrides,
14
+ })
15
+
16
+ test('convertDslToGraph: a scenario carries its skip reason into the graph', () => {
17
+ const graph = convertDslToGraph(
18
+ 'installAddonFreshNameScenario',
19
+ scenarioMeta({ skip: 'npm cannot install inside this yarn workspace' })
20
+ )
21
+
22
+ assert.strictEqual(
23
+ graph.skip,
24
+ 'npm cannot install inside this yarn workspace'
25
+ )
26
+ })
27
+
28
+ test('convertDslToGraph: a scenario with no skip leaves it unset', () => {
29
+ const graph = convertDslToGraph(
30
+ 'installAddonFreshNameScenario',
31
+ scenarioMeta()
32
+ )
33
+
34
+ assert.strictEqual(graph.skip, undefined)
35
+ })