@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.
- package/CHANGELOG.md +197 -0
- package/README.md +28 -2
- package/dist/add/add-feature.d.ts +19 -0
- package/dist/add/add-feature.js +200 -0
- package/dist/add/add-functions.js +24 -9
- package/dist/add/add-workflow.js +22 -29
- package/dist/error-codes.d.ts +3 -0
- package/dist/error-codes.js +3 -0
- package/dist/index.d.ts +1 -0
- package/dist/inspector.js +39 -2
- package/dist/types.d.ts +43 -0
- package/dist/utils/extract-function-name.js +27 -0
- package/dist/utils/post-process.d.ts +26 -0
- package/dist/utils/post-process.js +98 -0
- package/dist/utils/serialize-inspector-state.d.ts +2 -1
- package/dist/utils/serialize-inspector-state.js +2 -0
- package/dist/utils/workflow/dsl/extract-dsl-workflow.js +203 -19
- package/dist/utils/workflow/dsl/patterns.d.ts +21 -0
- package/dist/utils/workflow/dsl/patterns.js +30 -0
- package/dist/utils/workflow/dsl/validation.d.ts +5 -0
- package/dist/utils/workflow/dsl/validation.js +9 -0
- package/dist/utils/workflow/graph/convert-dsl-to-graph.js +36 -0
- package/dist/utils/workflow/graph/workflow-graph.types.d.ts +5 -1
- package/dist/visit.js +2 -0
- package/package.json +4 -3
- package/src/add/add-feature.test.ts +306 -0
- package/src/add/add-feature.ts +253 -0
- package/src/add/add-functions.ts +29 -9
- package/src/add/add-scenario-step.test.ts +311 -0
- package/src/add/add-workflow-silent-drops.test.ts +98 -0
- package/src/add/add-workflow.ts +25 -37
- package/src/add/scenario-not-extractable.test.ts +159 -0
- package/src/add/scenario-services.test.ts +103 -0
- package/src/add/scenario-skip.test.ts +83 -0
- package/src/error-codes.ts +3 -0
- package/src/index.ts +1 -0
- package/src/inspector.ts +50 -1
- package/src/tsconfig-paths.test.ts +90 -0
- package/src/types.ts +45 -0
- package/src/utils/extract-function-name.ts +36 -0
- package/src/utils/post-process.test.ts +36 -2
- package/src/utils/post-process.ts +111 -0
- package/src/utils/serialize-inspector-state.ts +8 -1
- package/src/utils/workflow/dsl/extract-dsl-workflow.ts +236 -22
- package/src/utils/workflow/dsl/patterns.ts +38 -0
- package/src/utils/workflow/dsl/validation.ts +12 -0
- package/src/utils/workflow/graph/convert-dsl-to-graph-skip.test.ts +35 -0
- package/src/utils/workflow/graph/convert-dsl-to-graph.ts +36 -0
- package/src/utils/workflow/graph/workflow-graph.types.ts +5 -0
- package/src/visit.ts +2 -0
- 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 {
|
|
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 (
|
|
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(
|
|
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
|
|
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
|
-
|
|
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 =
|
|
368
|
-
?
|
|
369
|
-
:
|
|
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 =
|
|
589
|
-
?
|
|
590
|
-
:
|
|
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 (
|
|
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 =
|
|
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 &&
|
|
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)
|
|
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
|
-
|
|
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
|
+
})
|