@pikku/inspector 0.12.47 → 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 (50) hide show
  1. package/CHANGELOG.md +185 -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 +85 -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.ts +97 -0
  42. package/src/utils/serialize-inspector-state.ts +8 -1
  43. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +236 -22
  44. package/src/utils/workflow/dsl/patterns.ts +38 -0
  45. package/src/utils/workflow/dsl/validation.ts +12 -0
  46. package/src/utils/workflow/graph/convert-dsl-to-graph-skip.test.ts +35 -0
  47. package/src/utils/workflow/graph/convert-dsl-to-graph.ts +36 -0
  48. package/src/utils/workflow/graph/workflow-graph.types.ts +5 -0
  49. package/src/visit.ts +2 -0
  50. package/tsconfig.tsbuildinfo +1 -1
@@ -1,5 +1,5 @@
1
1
  import * as ts from 'typescript';
2
- import { isWorkflowDoCall, isWorkflowExpectEventuallyCall, extractActorFromOptions, isWorkflowSleepCall, isWorkflowSuspendCall, isWorkflowApprovalCall, isThrowCancelException, extractCancelReason, isParallelFanout, isParallelGroup, isSequentialFanout, isArrayFilter, isArraySome, isArrayEvery, extractForOfVariable, isArrayType, getSourceText, extractSourcePath, } from './patterns.js';
2
+ import { isWorkflowDoCall, isWorkflowExpectEventuallyCall, isScenarioStepCall, getScenarioStepPhase, DYNAMIC_SCENARIO_STEP_TARGET, extractActorFromOptions, isWorkflowSleepCall, isWorkflowSuspendCall, isWorkflowApprovalCall, isThrowCancelException, extractCancelReason, isParallelFanout, isParallelGroup, isSequentialFanout, isArrayFilter, isArraySome, isArrayEvery, extractForOfVariable, isArrayType, getSourceText, extractSourcePath, } from './patterns.js';
3
3
  import { validateNoDisallowedPatterns, validateAwaitedCalls, formatValidationErrors, } from './validation.js';
4
4
  import { extractStringLiteral, extractNumberLiteral, } from '../../extract-node-value.js';
5
5
  /**
@@ -17,7 +17,7 @@ export function extractDSLWorkflow(funcNode, checker, options) {
17
17
  }
18
18
  // Extract input parameter name (second parameter)
19
19
  const inputParamName = extractInputParamName(arrowFunc);
20
- if (!inputParamName) {
20
+ if (inputParamName === undefined) {
21
21
  return {
22
22
  status: 'error',
23
23
  reason: 'Could not determine input parameter name',
@@ -121,7 +121,13 @@ function findWorkflowFunction(node) {
121
121
  return null;
122
122
  }
123
123
  /**
124
- * Extract the input parameter name from the arrow function
124
+ * Extract the input parameter name from the arrow function.
125
+ *
126
+ * Returns `null` when no input parameter is declared at all. That is
127
+ * legitimate — a workflow may ignore its input — and nothing in the body can
128
+ * reference it, so every name comparison against `null` correctly fails.
129
+ * Returns `undefined` when an input parameter is declared but is not a plain
130
+ * identifier (destructured), which the extractor cannot track by name.
125
131
  */
126
132
  function extractInputParamName(arrowFunc) {
127
133
  if (arrowFunc.parameters.length < 2) {
@@ -131,7 +137,7 @@ function extractInputParamName(arrowFunc) {
131
137
  if (ts.isIdentifier(secondParam.name)) {
132
138
  return secondParam.name.text;
133
139
  }
134
- return null;
140
+ return undefined;
135
141
  }
136
142
  /**
137
143
  * Extract steps from the function body
@@ -179,7 +185,33 @@ function extractStep(statement, context) {
179
185
  }
180
186
  // For-of statement (sequential fanout)
181
187
  if (ts.isForOfStatement(statement)) {
182
- return extractSequentialFanout(statement, context);
188
+ const reason = {};
189
+ const fanout = extractSequentialFanout(statement, context, reason);
190
+ if (!fanout && containsWorkflowCall(statement.statement, context.checker)) {
191
+ // A for-of the DSL can't model as a sequential fanout (its iterable is not
192
+ // a data-array identifier/field — e.g. a counting loop
193
+ // `for (const i of [...Array(N).keys()])`). Silently returning null here
194
+ // used to drop the loop AND every `workflow.do` inside it, so the workflow
195
+ // serialized with zero steps and its step functions were never registered
196
+ // (→ runtime "Function not found"). Fail loudly instead: pikkuWorkflowFunc
197
+ // reports INVALID_DSL_WORKFLOW; pikkuWorkflowComplexFunc falls back to the
198
+ // basic AST walk (which DOES register loop-invoked functions), so a genuine
199
+ // control-flow loop belongs in a complex workflow.
200
+ //
201
+ // Guarded on the body actually containing a workflow call: a loop that only
202
+ // massages locals (building a lookup, summing a total) has no step to lose,
203
+ // and erroring on it would be a false positive — the whole hazard is a
204
+ // dropped `workflow.do`, not a dropped `for`.
205
+ context.errors.push({
206
+ message: `The for-of loop '${statement.getText().slice(0, 60)}' can't be expressed in a DSL workflow — ` +
207
+ (reason.message ??
208
+ `its iterable must be a data array (an identifier or field like 'data.items'), not a computed/inline ` +
209
+ `iterable such as '[...Array(n).keys()]'. Use pikkuWorkflowComplexFunc for a control-flow/counting loop, ` +
210
+ `or iterate over a real input array.`),
211
+ node: statement,
212
+ });
213
+ }
214
+ return fanout;
183
215
  }
184
216
  // Return statement
185
217
  if (ts.isReturnStatement(statement)) {
@@ -231,10 +263,12 @@ function extractVariableDeclaration(statement, context) {
231
263
  // Check for await workflow.do(...)
232
264
  if (ts.isAwaitExpression(init) && ts.isCallExpression(init.expression)) {
233
265
  const call = init.expression;
234
- if (isWorkflowDoCall(call, context.checker)) {
235
- const step = isInlineDoCall(call)
236
- ? extractInlineStep(call, context)
237
- : extractRpcStep(call, context, varName);
266
+ if (isWorkflowDoCall(call, context.checker) || isScenarioStepCall(call)) {
267
+ const step = isScenarioStepCall(call)
268
+ ? extractScenarioStep(call, context, varName)
269
+ : isInlineDoCall(call)
270
+ ? extractInlineStep(call, context)
271
+ : extractRpcStep(call, context, varName);
238
272
  if (step) {
239
273
  // Track output variable
240
274
  const type = context.checker.getTypeAtLocation(decl);
@@ -356,6 +390,7 @@ function extractDestructuredDeclaration(statement, decl, context) {
356
390
  : null;
357
391
  const destructuresAStep = stepCall !== null &&
358
392
  (isWorkflowDoCall(stepCall, context.checker) ||
393
+ isScenarioStepCall(stepCall) ||
359
394
  isParallelGroup(stepCall) ||
360
395
  isParallelFanout(stepCall));
361
396
  if (!destructuresAStep)
@@ -413,10 +448,12 @@ function extractExpressionStatement(statement, context) {
413
448
  // await workflow.do(...)
414
449
  if (ts.isAwaitExpression(expr) && ts.isCallExpression(expr.expression)) {
415
450
  const call = expr.expression;
416
- if (isWorkflowDoCall(call, context.checker)) {
417
- const step = isInlineDoCall(call)
418
- ? extractInlineStep(call, context)
419
- : extractRpcStep(call, context, outputVar);
451
+ if (isWorkflowDoCall(call, context.checker) || isScenarioStepCall(call)) {
452
+ const step = isScenarioStepCall(call)
453
+ ? extractScenarioStep(call, context, outputVar)
454
+ : isInlineDoCall(call)
455
+ ? extractInlineStep(call, context)
456
+ : extractRpcStep(call, context, outputVar);
420
457
  // Track output variable if this is an assignment
421
458
  if (outputVar && step) {
422
459
  const type = context.checker.getTypeAtLocation(expr);
@@ -496,6 +533,57 @@ function extractRpcStep(call, context, outputVar) {
496
533
  return null;
497
534
  }
498
535
  }
536
+ /**
537
+ * Extract a scenario step from scenario.step/given/when/then(stepName,
538
+ * stepFunc, data?, options?).
539
+ *
540
+ * The step target must be a static string literal so it can be bundled, typed
541
+ * and drawn. When it isn't, the step is still recorded — with a `<dynamic>`
542
+ * target — so post-processing can raise PKU678 rather than the call silently
543
+ * vanishing from the graph.
544
+ */
545
+ function extractScenarioStep(call, context, outputVar) {
546
+ const phase = getScenarioStepPhase(call);
547
+ if (!phase) {
548
+ return null;
549
+ }
550
+ const args = call.arguments;
551
+ if (args.length < 2) {
552
+ return null;
553
+ }
554
+ try {
555
+ const stepName = extractStringLiteral(args[0], context.checker);
556
+ let stepFunc = DYNAMIC_SCENARIO_STEP_TARGET;
557
+ try {
558
+ stepFunc = extractStringLiteral(args[1], context.checker);
559
+ }
560
+ catch {
561
+ // Left as '<dynamic>' — validated in post-processing (PKU678).
562
+ }
563
+ const inputs = args.length >= 3 ? extractInputSources(args[2], context) : undefined;
564
+ const optionsArg = args.length >= 4 ? args[3] : undefined;
565
+ const options = optionsArg && ts.isObjectLiteralExpression(optionsArg)
566
+ ? extractStepOptions(optionsArg, context)
567
+ : undefined;
568
+ return {
569
+ type: 'scenarioStep',
570
+ stepName,
571
+ stepFunc,
572
+ phase,
573
+ outputVar,
574
+ inputs,
575
+ options,
576
+ actor: extractActorFromOptions(optionsArg),
577
+ };
578
+ }
579
+ catch (error) {
580
+ context.errors.push({
581
+ message: `Failed to extract scenario step: ${error instanceof Error ? error.message : String(error)}`,
582
+ node: call,
583
+ });
584
+ return null;
585
+ }
586
+ }
499
587
  /**
500
588
  * Extract inline step from workflow.do() call with a function argument
501
589
  */
@@ -952,11 +1040,14 @@ function extractFanoutBodyStep(stmt, childContext, body) {
952
1040
  return;
953
1041
  }
954
1042
  const call = init.expression;
955
- if (!isWorkflowDoCall(call, childContext.checker)) {
1043
+ if (!isWorkflowDoCall(call, childContext.checker) &&
1044
+ !isScenarioStepCall(call)) {
956
1045
  return;
957
1046
  }
958
1047
  const varName = ts.isIdentifier(decl.name) ? decl.name.text : undefined;
959
- const step = extractRpcStep(call, childContext, varName);
1048
+ const step = isScenarioStepCall(call)
1049
+ ? extractScenarioStep(call, childContext, varName)
1050
+ : extractRpcStep(call, childContext, varName);
960
1051
  if (!step) {
961
1052
  return;
962
1053
  }
@@ -999,6 +1090,13 @@ function extractFanoutBodyStep(stmt, childContext, body) {
999
1090
  }
1000
1091
  return;
1001
1092
  }
1093
+ if (isScenarioStepCall(call)) {
1094
+ const scenarioStep = extractScenarioStep(call, childContext);
1095
+ if (scenarioStep) {
1096
+ body.push(scenarioStep);
1097
+ }
1098
+ return;
1099
+ }
1002
1100
  if (!isWorkflowDoCall(call, childContext.checker)) {
1003
1101
  return;
1004
1102
  }
@@ -1059,7 +1157,13 @@ function extractParallelFanout(call, context) {
1059
1157
  : ts.isCallExpression(expr)
1060
1158
  ? expr
1061
1159
  : null;
1062
- if (doCall && isWorkflowDoCall(doCall, context.checker)) {
1160
+ if (doCall && isScenarioStepCall(doCall)) {
1161
+ const step = extractScenarioStep(doCall, childContext);
1162
+ if (step) {
1163
+ body.push(step);
1164
+ }
1165
+ }
1166
+ else if (doCall && isWorkflowDoCall(doCall, context.checker)) {
1063
1167
  const step = extractRpcStep(doCall, childContext);
1064
1168
  if (step) {
1065
1169
  body.push(step);
@@ -1087,7 +1191,16 @@ function extractParallelGroup(call, context) {
1087
1191
  }
1088
1192
  const children = [];
1089
1193
  for (const elem of arrayArg.elements) {
1090
- if (ts.isCallExpression(elem) && isWorkflowDoCall(elem, context.checker)) {
1194
+ if (!ts.isCallExpression(elem)) {
1195
+ continue;
1196
+ }
1197
+ if (isScenarioStepCall(elem)) {
1198
+ const step = extractScenarioStep(elem, context);
1199
+ if (step) {
1200
+ children.push(step);
1201
+ }
1202
+ }
1203
+ else if (isWorkflowDoCall(elem, context.checker)) {
1091
1204
  const step = extractRpcStep(elem, context);
1092
1205
  if (step) {
1093
1206
  children.push(step);
@@ -1105,7 +1218,35 @@ function extractParallelGroup(call, context) {
1105
1218
  /**
1106
1219
  * Extract sequential fanout from for-of loop
1107
1220
  */
1108
- function extractSequentialFanout(statement, context) {
1221
+ /**
1222
+ * Does this subtree call the workflow wire at all?
1223
+ *
1224
+ * Used to tell "the DSL can't model this loop, and a step would be silently
1225
+ * lost" from "the DSL can't model this loop, and there is nothing in it to
1226
+ * lose". Only the first is worth failing a build over.
1227
+ */
1228
+ function containsWorkflowCall(node, checker) {
1229
+ let found = false;
1230
+ const visit = (child) => {
1231
+ if (found)
1232
+ return;
1233
+ if (ts.isCallExpression(child)) {
1234
+ if (isWorkflowDoCall(child, checker) ||
1235
+ isScenarioStepCall(child) ||
1236
+ isWorkflowSleepCall(child, checker) ||
1237
+ isWorkflowSuspendCall(child, checker) ||
1238
+ isWorkflowApprovalCall(child, checker) ||
1239
+ isWorkflowExpectEventuallyCall(child)) {
1240
+ found = true;
1241
+ return;
1242
+ }
1243
+ }
1244
+ ts.forEachChild(child, visit);
1245
+ };
1246
+ visit(node);
1247
+ return found;
1248
+ }
1249
+ function extractSequentialFanout(statement, context, reason) {
1109
1250
  if (!isSequentialFanout(statement)) {
1110
1251
  return null;
1111
1252
  }
@@ -1189,6 +1330,16 @@ function extractSequentialFanout(statement, context) {
1189
1330
  }
1190
1331
  }
1191
1332
  if (body.length === 0) {
1333
+ // The iterable was fine — the body yielded nothing the DSL can model. The
1334
+ // usual cause is a `workflow.do` nested inside an `if`/`switch`, because
1335
+ // `FanoutStepMeta['body']` is a flat list of steps with no branch member:
1336
+ // the DSL has no way to say "conditionally run this step per item".
1337
+ const branching = bodyStatements.some((stmt) => ts.isIfStatement(stmt) || ts.isSwitchStatement(stmt));
1338
+ if (reason) {
1339
+ reason.message = branching
1340
+ ? `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.`
1341
+ : `its body contains no 'workflow.do' call the DSL can model. Use pikkuWorkflowComplexFunc for a loop that does other work.`;
1342
+ }
1192
1343
  return null;
1193
1344
  }
1194
1345
  return {
@@ -1374,7 +1525,11 @@ function extractInputSources(node, context) {
1374
1525
  source = { from: 'outputVar', name: varName };
1375
1526
  }
1376
1527
  else {
1377
- source = { from: 'input', path: varName };
1528
+ const constant = resolveConstantValue(prop.name, context.checker);
1529
+ source =
1530
+ constant !== undefined
1531
+ ? { from: 'literal', value: constant }
1532
+ : { from: 'input', path: varName };
1378
1533
  }
1379
1534
  }
1380
1535
  else if (ts.isPropertyAssignment(prop)) {
@@ -1449,6 +1604,10 @@ function extractInputSource(node, context) {
1449
1604
  if (context.outputVars.has(varName)) {
1450
1605
  return { from: 'outputVar', name: varName };
1451
1606
  }
1607
+ const constant = resolveConstantValue(node, context.checker);
1608
+ if (constant !== undefined) {
1609
+ return { from: 'literal', value: constant };
1610
+ }
1452
1611
  // Assume it's from input
1453
1612
  return { from: 'input', path: varName };
1454
1613
  }
@@ -1528,6 +1687,31 @@ function extractInputSource(node, context) {
1528
1687
  }
1529
1688
  return null;
1530
1689
  }
1690
+ /**
1691
+ * Resolve an identifier that names a constant to the value it was declared with.
1692
+ *
1693
+ * Without this, a name the extractor does not recognise falls through to
1694
+ * `{ from: 'input' }` and is serialized as `{ $ref: 'trigger', path: <name> }` —
1695
+ * so a module-level `const RESOURCE_ID = 'x'` used as step input silently
1696
+ * becomes a read of a trigger field that does not exist. Only `const`
1697
+ * declarations with a fully literal initializer resolve; anything else (a name
1698
+ * bound out of the trigger, a computed value) falls through unchanged.
1699
+ */
1700
+ function resolveConstantValue(node, checker) {
1701
+ const symbol = checker.getSymbolAtLocation(node);
1702
+ const declaration = symbol?.valueDeclaration ?? symbol?.declarations?.[0];
1703
+ if (!declaration || !ts.isVariableDeclaration(declaration)) {
1704
+ return undefined;
1705
+ }
1706
+ const declarationList = declaration.parent;
1707
+ if (!ts.isVariableDeclarationList(declarationList) ||
1708
+ !(declarationList.flags & ts.NodeFlags.Const)) {
1709
+ return undefined;
1710
+ }
1711
+ return declaration.initializer
1712
+ ? extractLiteralValue(declaration.initializer)
1713
+ : undefined;
1714
+ }
1531
1715
  /**
1532
1716
  * Extract a literal value from an expression
1533
1717
  */
@@ -13,6 +13,26 @@ export declare function isWorkflowWireIdentifier(expr: ts.Expression): boolean;
13
13
  * (both are RPC steps; expectEventually is the polling variant used by scenarios)
14
14
  */
15
15
  export declare function isWorkflowDoCall(node: ts.CallExpression, checker: ts.TypeChecker): boolean;
16
+ /**
17
+ * Placeholder recorded when a scenario step's target isn't a static string
18
+ * literal. The step is kept rather than dropped so post-processing can raise
19
+ * PKU678 against it — a dropped step is the silent-failure mode this repo has
20
+ * hit repeatedly on workflow graph round-trips.
21
+ */
22
+ export declare const DYNAMIC_SCENARIO_STEP_TARGET = "<dynamic>";
23
+ declare const SCENARIO_STEP_PHASES: readonly ["step", "given", "when", "then"];
24
+ export type ScenarioStepPhaseName = (typeof SCENARIO_STEP_PHASES)[number];
25
+ /**
26
+ * Check if a call expression is scenario.step()/given()/when()/then() — a
27
+ * declared `pikkuScenarioStep` invoked by name. `given`/`when`/`then` are pure
28
+ * sugar over `step`: the phase only changes the prose a reporter renders.
29
+ */
30
+ export declare function isScenarioStepCall(node: ts.CallExpression): boolean;
31
+ /**
32
+ * The Gherkin-style phase of a scenario step call, or undefined when the call
33
+ * isn't one.
34
+ */
35
+ export declare function getScenarioStepPhase(node: ts.CallExpression): ScenarioStepPhaseName | undefined;
16
36
  /**
17
37
  * Check if a call expression is workflow.expectEventually()
18
38
  */
@@ -87,3 +107,4 @@ export declare function isArrayType(type: ts.Type, checker: ts.TypeChecker): boo
87
107
  * Get the source text of a node (for condition expressions)
88
108
  */
89
109
  export declare function getSourceText(node: ts.Node): string;
110
+ export {};
@@ -24,6 +24,36 @@ export function isWorkflowDoCall(node, checker) {
24
24
  propAccess.name.text === 'expectEventually') &&
25
25
  isWorkflowWireIdentifier(propAccess.expression));
26
26
  }
27
+ /**
28
+ * Placeholder recorded when a scenario step's target isn't a static string
29
+ * literal. The step is kept rather than dropped so post-processing can raise
30
+ * PKU678 against it — a dropped step is the silent-failure mode this repo has
31
+ * hit repeatedly on workflow graph round-trips.
32
+ */
33
+ export const DYNAMIC_SCENARIO_STEP_TARGET = '<dynamic>';
34
+ const SCENARIO_STEP_PHASES = ['step', 'given', 'when', 'then'];
35
+ /**
36
+ * Check if a call expression is scenario.step()/given()/when()/then() — a
37
+ * declared `pikkuScenarioStep` invoked by name. `given`/`when`/`then` are pure
38
+ * sugar over `step`: the phase only changes the prose a reporter renders.
39
+ */
40
+ export function isScenarioStepCall(node) {
41
+ return getScenarioStepPhase(node) !== undefined;
42
+ }
43
+ /**
44
+ * The Gherkin-style phase of a scenario step call, or undefined when the call
45
+ * isn't one.
46
+ */
47
+ export function getScenarioStepPhase(node) {
48
+ if (!ts.isPropertyAccessExpression(node.expression)) {
49
+ return;
50
+ }
51
+ const propAccess = node.expression;
52
+ if (!isWorkflowWireIdentifier(propAccess.expression)) {
53
+ return;
54
+ }
55
+ return SCENARIO_STEP_PHASES.find((phase) => phase === propAccess.name.text);
56
+ }
27
57
  /**
28
58
  * Check if a call expression is workflow.expectEventually()
29
59
  */
@@ -18,6 +18,11 @@ export interface ValidationError {
18
18
  * - ReturnStatement
19
19
  * - ThrowStatement (for WorkflowCancelledException)
20
20
  * - Block (containers)
21
+ *
22
+ * When `allowInline` is set (complex workflows and scenarios), the bodies of
23
+ * closures are skipped: a callback is ordinary TypeScript, not DSL, so holding
24
+ * it to the statement whitelist would reject valid code. Plain DSL workflows
25
+ * still descend into callbacks, which is what validates fanout bodies.
21
26
  */
22
27
  export declare function validateNoDisallowedPatterns(node: ts.Node, options?: {
23
28
  allowInline?: boolean;
@@ -12,6 +12,11 @@ import { isWorkflowWireIdentifier } from './patterns.js';
12
12
  * - ReturnStatement
13
13
  * - ThrowStatement (for WorkflowCancelledException)
14
14
  * - Block (containers)
15
+ *
16
+ * When `allowInline` is set (complex workflows and scenarios), the bodies of
17
+ * closures are skipped: a callback is ordinary TypeScript, not DSL, so holding
18
+ * it to the statement whitelist would reject valid code. Plain DSL workflows
19
+ * still descend into callbacks, which is what validates fanout bodies.
15
20
  */
16
21
  export function validateNoDisallowedPatterns(node, options) {
17
22
  const errors = [];
@@ -38,6 +43,10 @@ export function validateNoDisallowedPatterns(node, options) {
38
43
  }
39
44
  }
40
45
  function visitNode(node) {
46
+ if (options?.allowInline &&
47
+ (ts.isArrowFunction(node) || ts.isFunctionExpression(node))) {
48
+ return;
49
+ }
41
50
  // Disallow while and do-while
42
51
  if (ts.isWhileStatement(node) || ts.isDoStatement(node)) {
43
52
  errors.push({
@@ -103,6 +103,41 @@ function convertStepToNode(step, index, steps, nodeIdPrefix = 'step') {
103
103
  }
104
104
  return [node];
105
105
  }
106
+ // A declared step is a function node like any other — the console draws it
107
+ // the same way, with the phase available for the "Given/When/Then" prose.
108
+ case 'scenarioStep': {
109
+ const node = {
110
+ nodeId,
111
+ rpcName: step.stepFunc,
112
+ stepName: step.stepName,
113
+ scenarioStepPhase: step.phase,
114
+ next: nextNodeId,
115
+ };
116
+ if (step.actor) {
117
+ node.actor = step.actor;
118
+ }
119
+ if (step.inputs) {
120
+ if (step.inputs === 'passthrough') {
121
+ node.input = { $passthrough: { $ref: 'trigger' } };
122
+ }
123
+ else {
124
+ node.input = {};
125
+ for (const [key, source] of Object.entries(step.inputs)) {
126
+ node.input[key] = convertInputSource(source);
127
+ }
128
+ }
129
+ }
130
+ if (step.outputVar) {
131
+ node.outputVar = step.outputVar;
132
+ }
133
+ if (step.options) {
134
+ node.options = {
135
+ retries: step.options.retries,
136
+ retryDelay: step.options.retryDelay,
137
+ };
138
+ }
139
+ return [node];
140
+ }
106
141
  case 'sleep': {
107
142
  const node = {
108
143
  nodeId,
@@ -384,6 +419,7 @@ export function convertDslToGraph(workflowName, meta) {
384
419
  title: meta.title,
385
420
  description: meta.description,
386
421
  tags: meta.tags,
422
+ skip: meta.skip,
387
423
  actors: meta.actors,
388
424
  context: meta.context,
389
425
  nodes: nodesRecord,
@@ -67,7 +67,7 @@ export interface NodeOptions {
67
67
  * Flow node types for control flow (no RPC call)
68
68
  */
69
69
  export type FlowType = 'sleep' | 'suspend' | 'approval' | 'branch' | 'parallel' | 'fanout' | 'inline' | 'switch' | 'filter' | 'arrayPredicate' | 'return' | 'cancel' | 'set';
70
- import type { ContextVariable, WorkflowContext, WorkflowPlannedStep } from '@pikku/core/workflow';
70
+ import type { ContextVariable, ScenarioStepPhase, WorkflowContext, WorkflowPlannedStep } from '@pikku/core/workflow';
71
71
  export type { ContextVariable, WorkflowContext };
72
72
  /**
73
73
  * Base node properties shared by all node types
@@ -102,6 +102,8 @@ export interface FunctionNode extends BaseNode {
102
102
  actor?: string;
103
103
  /** True for workflow.expectEventually polling steps (scenarios) */
104
104
  expectEventually?: boolean;
105
+ /** Gherkin-style phase for a declared scenario step (scenario.given/when/then) */
106
+ scenarioStepPhase?: ScenarioStepPhase;
105
107
  }
106
108
  /**
107
109
  * Flow node - control flow only, no RPC call
@@ -150,6 +152,8 @@ export interface SerializedWorkflowGraph {
150
152
  tags?: string[];
151
153
  /** Actor names a scenario's steps run as */
152
154
  actors?: string[];
155
+ /** Why a scenario is held out of a default run, stated where it is declared */
156
+ skip?: string;
153
157
  /** If true, workflow always executes inline without queues */
154
158
  inline?: boolean;
155
159
  /** Workflow context/state variables (from Zod schema) */
package/dist/visit.js CHANGED
@@ -25,6 +25,7 @@ import { addCredential } from './add/add-credential.js';
25
25
  import { addScope } from './add/add-scope.js';
26
26
  import { addVariable } from './add/add-variable.js';
27
27
  import { addWorkflowGraph } from './add/add-workflow-graph.js';
28
+ import { addFeature } from './add/add-feature.js';
28
29
  import { addAIAgent } from './add/add-ai-agent.js';
29
30
  import { addApprovalDescription } from './add/add-approval-description.js';
30
31
  export const visitSetup = (logger, checker, node, state, options) => {
@@ -81,6 +82,7 @@ export const visitRoutes = (logger, checker, node, state, options) => {
81
82
  addMCPResource(logger, node, checker, state, nextOptions);
82
83
  addMCPPrompt(logger, node, checker, state, nextOptions);
83
84
  addWorkflowGraph(logger, node, checker, state, nextOptions);
85
+ addFeature(logger, node, checker, state, nextOptions);
84
86
  addAIAgent(logger, node, checker, state, nextOptions);
85
87
  ts.forEachChild(node, (child) => visitRoutes(logger, checker, child, state, nextOptions));
86
88
  };
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@pikku/inspector",
3
- "version": "0.12.47",
3
+ "version": "0.12.48",
4
+ "description": "TypeScript inspector for Pikku — discovers functions and wirings to drive code generation",
4
5
  "author": "yasser.fadl@gmail.com",
5
6
  "license": "BUSL-1.1",
6
7
  "type": "module",
@@ -44,10 +45,10 @@
44
45
  "zod-to-ts": "^2.1.0"
45
46
  },
46
47
  "peerDependencies": {
47
- "@pikku/core": "^0.12.68"
48
+ "@pikku/core": "^0.12.70"
48
49
  },
49
50
  "devDependencies": {
50
- "@pikku/core": "^0.12.68",
51
+ "@pikku/core": "^0.12.70",
51
52
  "@types/node": "^24.11.0"
52
53
  },
53
54
  "engines": {