@output.ai/core 0.1.8 → 0.1.9
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/README.md +79 -227
- package/package.json +5 -2
- package/src/tracing/trace_engine.js +9 -1
- package/src/worker/webpack_loaders/tools.js +117 -1
- package/src/worker/webpack_loaders/workflow_rewriter/collect_target_imports.spec.js +20 -26
- package/src/worker/webpack_loaders/workflow_rewriter/index.spec.js +73 -88
- package/src/worker/webpack_loaders/workflow_rewriter/rewrite_fn_bodies.js +157 -33
- package/src/worker/webpack_loaders/workflow_rewriter/rewrite_fn_bodies.spec.js +91 -15
package/README.md
CHANGED
|
@@ -1,246 +1,98 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @output.ai/core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Workflow orchestration and worker runtime for building durable LLM applications with Temporal.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[](https://www.npmjs.com/package/@output.ai/core)
|
|
6
|
+
[](https://docs.output.ai/packages/core)
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
```
|
|
10
|
-
└ workflows
|
|
11
|
-
└ example
|
|
12
|
-
├ workflow.ts|js <- workflow entry point
|
|
13
|
-
├ steps.ts|js <- file containing steps used by the workflow
|
|
14
|
-
├ evaluators.ts|js <- file containing evaluating functions
|
|
15
|
-
└ prompt.prompt <- a prompt file
|
|
16
|
-
└ other-example
|
|
8
|
+
## Installation
|
|
17
9
|
|
|
10
|
+
```bash
|
|
11
|
+
npm install @output.ai/core
|
|
18
12
|
```
|
|
19
13
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
## Components
|
|
14
|
+
## Quick Start
|
|
23
15
|
|
|
24
|
-
|
|
16
|
+
```typescript
|
|
17
|
+
// workflow.ts
|
|
18
|
+
import { workflow, z } from '@output.ai/core';
|
|
19
|
+
import { processData } from './steps.js';
|
|
25
20
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
import { guessByName } from './steps.js';
|
|
34
|
-
|
|
35
|
-
export default workflow( {
|
|
36
|
-
name: 'guessMyProfession',
|
|
37
|
-
description: 'Guess a person profession by its name',
|
|
38
|
-
inputSchema: z.object( {
|
|
39
|
-
name: z.string()
|
|
40
|
-
} ),
|
|
41
|
-
outputSchema: z.object( {
|
|
42
|
-
profession: z.string()
|
|
43
|
-
} ),
|
|
44
|
-
fn: async input => {
|
|
45
|
-
const profession = await guessByName( input.name );
|
|
46
|
-
return { profession };
|
|
21
|
+
export default workflow({
|
|
22
|
+
name: 'myWorkflow',
|
|
23
|
+
inputSchema: z.object({ text: z.string() }),
|
|
24
|
+
outputSchema: z.object({ result: z.string() }),
|
|
25
|
+
fn: async (input) => {
|
|
26
|
+
const result = await processData(input.text);
|
|
27
|
+
return { result };
|
|
47
28
|
}
|
|
48
|
-
})
|
|
29
|
+
});
|
|
49
30
|
```
|
|
50
31
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
- `evaluators.js`
|
|
55
|
-
- `shared_steps.js`
|
|
56
|
-
- `steps.js`
|
|
57
|
-
- `workflow.js`
|
|
32
|
+
```typescript
|
|
33
|
+
// steps.ts
|
|
34
|
+
import { step, z } from '@output.ai/core';
|
|
58
35
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
#### Whitelisted files
|
|
63
|
-
- `types.js`
|
|
64
|
-
- `consts.js`
|
|
65
|
-
- `constants.js`
|
|
66
|
-
- `vars.js`
|
|
67
|
-
- `variables.js`
|
|
68
|
-
- `utils.js`
|
|
69
|
-
- `tools.js`
|
|
70
|
-
- `functions.js`
|
|
71
|
-
- `shared.js`
|
|
72
|
-
|
|
73
|
-
### Step
|
|
74
|
-
|
|
75
|
-
Re-usable units of work that can contain IO, used by the workflow.
|
|
76
|
-
|
|
77
|
-
File: `steps.js`
|
|
78
|
-
|
|
79
|
-
Example:
|
|
80
|
-
```js
|
|
81
|
-
import { api } from './api.js'
|
|
82
|
-
|
|
83
|
-
export const guessByName = step( {
|
|
84
|
-
name: 'guessByName',
|
|
36
|
+
export const processData = step({
|
|
37
|
+
name: 'processData',
|
|
85
38
|
inputSchema: z.string(),
|
|
86
39
|
outputSchema: z.string(),
|
|
87
|
-
fn: async
|
|
88
|
-
|
|
89
|
-
return res.body;
|
|
90
|
-
}
|
|
91
|
-
} )
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
### Shared Steps
|
|
95
|
-
|
|
96
|
-
By default, steps are exclusive to the workflow, so it is not passible to use these steps from elsewhere. In order to have shared steps and make them accessible in different workflows, create a shared steps file. This file can be relatively imported anywhere.
|
|
97
|
-
|
|
98
|
-
File: `shared_steps.js`
|
|
99
|
-
|
|
100
|
-
Example:
|
|
101
|
-
```js
|
|
102
|
-
export const mySharedStep = step( {
|
|
103
|
-
name: 'mySharedStep',
|
|
104
|
-
...
|
|
105
|
-
} )
|
|
106
|
-
```
|
|
107
|
-
|
|
108
|
-
And the usage is the same as any step:
|
|
109
|
-
`workflow.js`
|
|
110
|
-
```js
|
|
111
|
-
import { mySharedStep } from '../../tools/shared_steps.js'
|
|
112
|
-
```
|
|
113
|
-
|
|
114
|
-
### Evaluators
|
|
115
|
-
|
|
116
|
-
Steps that analyze LLM response, or take other measurements are contained in evaluators.
|
|
117
|
-
|
|
118
|
-
File: `evaluators.js`
|
|
119
|
-
|
|
120
|
-
Example:
|
|
121
|
-
```js
|
|
122
|
-
import { evaluator, EvaluationStringResult } from './api.js'
|
|
123
|
-
|
|
124
|
-
export const judgeResult = evaluator( {
|
|
125
|
-
name: 'judgeResult',
|
|
126
|
-
inputSchema: z.string(),
|
|
127
|
-
fn: async name => {
|
|
128
|
-
...
|
|
129
|
-
return new EvaluationStringResult({
|
|
130
|
-
value: 'good',
|
|
131
|
-
confidence: .95
|
|
132
|
-
});
|
|
40
|
+
fn: async (text) => {
|
|
41
|
+
return text.toUpperCase();
|
|
133
42
|
}
|
|
134
|
-
}
|
|
43
|
+
});
|
|
135
44
|
```
|
|
136
45
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
```js
|
|
163
|
-
import { workflow, createWebhook } from '@output.ai/workflow';
|
|
164
|
-
import { guessByName } from './steps.js';
|
|
165
|
-
|
|
166
|
-
export default workflow( {
|
|
167
|
-
...
|
|
168
|
-
fn: async input => {
|
|
169
|
-
...
|
|
170
|
-
|
|
171
|
-
const result = await createWebhook( {
|
|
172
|
-
url: 'http://xxx.xxx/feedback',
|
|
173
|
-
payload: {
|
|
174
|
-
progressSoFar: 'plenty'
|
|
175
|
-
}
|
|
176
|
-
} );
|
|
177
|
-
|
|
178
|
-
}
|
|
179
|
-
})
|
|
180
|
-
```
|
|
181
|
-
|
|
182
|
-
The url of the example will receive the payload, plus the workflowId:
|
|
183
|
-
|
|
184
|
-
```js
|
|
185
|
-
{
|
|
186
|
-
workflowId: '', // alphanumerical id of the workflow execution,
|
|
187
|
-
payload: { }, // the payload sent using tools.webhook()
|
|
188
|
-
}
|
|
189
|
-
```
|
|
190
|
-
|
|
191
|
-
To resume the workflow, a POST has to be made with a response payload and the workflowId.
|
|
192
|
-
|
|
193
|
-
- Production: `https://output-api-production.onrender.com/workflow/feedback`
|
|
194
|
-
- Local: `http://localhost:3001/workflow/feedback`
|
|
195
|
-
|
|
196
|
-
Example:
|
|
197
|
-
|
|
198
|
-
```bash
|
|
199
|
-
POST http://locahost:3001/workflow/feedback
|
|
200
|
-
{
|
|
201
|
-
workflowId,
|
|
202
|
-
payload: {}
|
|
203
|
-
}
|
|
204
|
-
```
|
|
205
|
-
|
|
206
|
-
## Options
|
|
207
|
-
|
|
208
|
-
All core interface functions: workflow, step, evaluator have similar signature, with the following options:
|
|
209
|
-
- name: The function name, used to call it internally and identify it in the trace files, must be a code friendly string;
|
|
210
|
-
- description: Human description of the workflow/step, used for the catalog;
|
|
211
|
-
- inputSchema: a zod object indicating the type of the argument received by the `fn` function. It is validated. Omit if it doesn't have input arguments;
|
|
212
|
-
- outputSchema: a zod object indicating the type of that the `fn` function returns. It is validated. Omit if it is void. Evaluators do not have this option, since they must always return an EvaluationResult object;
|
|
213
|
-
- fn: The actual implementation of the workflow/step, including all its logic.
|
|
214
|
-
- options: Advanced options that will overwrite Temporal's ActivityOptions when calling activities.
|
|
215
|
-
|
|
216
|
-
If used on `workflow()` it will apply for all activities. If used on `step()` or `evaluator()` it will apply only to that underlying activity. If changed in both places, the end value will be a merge between the initial values, workflow values and the step values.
|
|
217
|
-
|
|
218
|
-
Order of precedence
|
|
219
|
-
`step options > workflow options > default options`
|
|
220
|
-
|
|
221
|
-
## Developing
|
|
222
|
-
|
|
223
|
-
To develop workflows you need the code, which will be called the worker, the API and the engine (Temporal).
|
|
224
|
-
|
|
225
|
-
After having the API and the engine running, to start the worker just run:
|
|
226
|
-
|
|
227
|
-
```js
|
|
228
|
-
`npm run outputai`
|
|
46
|
+
## Key Exports
|
|
47
|
+
|
|
48
|
+
| Export | Description |
|
|
49
|
+
|--------|-------------|
|
|
50
|
+
| `workflow` | Define orchestration logic that coordinates steps |
|
|
51
|
+
| `step` | Define reusable units of work that handle I/O |
|
|
52
|
+
| `evaluator` | Define steps that return evaluation results |
|
|
53
|
+
| `createWebhook` | Pause workflow execution until external input |
|
|
54
|
+
| `z` | Zod schema library for input/output validation |
|
|
55
|
+
|
|
56
|
+
## File Structure
|
|
57
|
+
|
|
58
|
+
Each workflow lives in its own directory:
|
|
59
|
+
|
|
60
|
+
```text
|
|
61
|
+
src/workflows/
|
|
62
|
+
└── my-workflow/
|
|
63
|
+
├── workflow.ts # Workflow definition
|
|
64
|
+
├── steps.ts # Step implementations
|
|
65
|
+
├── evaluators.ts # Evaluators (optional)
|
|
66
|
+
├── prompts/ # LLM prompt templates
|
|
67
|
+
│ └── prompt@v1.prompt
|
|
68
|
+
└── scenarios/ # Test scenarios
|
|
69
|
+
└── test_input.json
|
|
229
70
|
```
|
|
230
71
|
|
|
231
|
-
##
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
72
|
+
## Environment Variables
|
|
73
|
+
|
|
74
|
+
The worker reads these environment variables:
|
|
75
|
+
|
|
76
|
+
| Variable | Description |
|
|
77
|
+
|----------|-------------|
|
|
78
|
+
| `TEMPORAL_ADDRESS` | Temporal backend address |
|
|
79
|
+
| `TEMPORAL_NAMESPACE` | Temporal namespace name |
|
|
80
|
+
| `TEMPORAL_API_KEY` | API key for remote Temporal (leave blank for local) |
|
|
81
|
+
| `CATALOG_ID` | **Required.** Name of the local catalog (use your email) |
|
|
82
|
+
| `API_AUTH_KEY` | API key for Framework API (blank for local, required for remote) |
|
|
83
|
+
| `TRACE_LOCAL_ON` | Enable local trace saving (requires `REDIS_URL`) |
|
|
84
|
+
| `TRACE_REMOTE_ON` | Enable remote trace saving (requires `REDIS_URL` and AWS secrets) |
|
|
85
|
+
| `REDIS_URL` | Redis address (required when tracing is enabled) |
|
|
86
|
+
| `TRACE_REMOTE_S3_BUCKET` | AWS S3 bucket for traces (required for remote tracing) |
|
|
87
|
+
| `AWS_REGION` | AWS region matching the S3 bucket (required for remote tracing) |
|
|
88
|
+
| `AWS_ACCESS_KEY_ID` | AWS key ID (required for remote tracing) |
|
|
89
|
+
| `AWS_SECRET_ACCESS_KEY` | AWS secret key (required for remote tracing) |
|
|
90
|
+
|
|
91
|
+
## Documentation
|
|
92
|
+
|
|
93
|
+
For comprehensive documentation, visit:
|
|
94
|
+
|
|
95
|
+
- [Package Reference](https://docs.output.ai/packages/core)
|
|
96
|
+
- [Workflows Guide](https://docs.output.ai/core/workflows)
|
|
97
|
+
- [Steps Guide](https://docs.output.ai/core/steps)
|
|
98
|
+
- [Getting Started](https://docs.output.ai/quickstart)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@output.ai/core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "The core module of the output framework",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -40,7 +40,10 @@
|
|
|
40
40
|
"stacktrace-parser": "0.1.11",
|
|
41
41
|
"zod": "4.1.12"
|
|
42
42
|
},
|
|
43
|
-
"license": "
|
|
43
|
+
"license": "Apache-2.0",
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
44
47
|
"imports": {
|
|
45
48
|
"#consts": "./src/consts.js",
|
|
46
49
|
"#errors": "./src/errors.js",
|
|
@@ -9,11 +9,13 @@ const traceBus = new EventEmitter();
|
|
|
9
9
|
const processors = [
|
|
10
10
|
{
|
|
11
11
|
isOn: isStringboolTrue( process.env.TRACE_LOCAL_ON ),
|
|
12
|
+
name: 'LOCAL',
|
|
12
13
|
init: localProcessor.init,
|
|
13
14
|
exec: localProcessor.exec
|
|
14
15
|
},
|
|
15
16
|
{
|
|
16
17
|
isOn: isStringboolTrue( process.env.TRACE_REMOTE_ON ),
|
|
18
|
+
name: 'REMOTE',
|
|
17
19
|
init: s3Processor.init,
|
|
18
20
|
exec: s3Processor.exec
|
|
19
21
|
}
|
|
@@ -25,7 +27,13 @@ const processors = [
|
|
|
25
27
|
export const init = async () => {
|
|
26
28
|
for ( const p of processors.filter( p => p.isOn ) ) {
|
|
27
29
|
await p.init();
|
|
28
|
-
traceBus.addListener( 'entry',
|
|
30
|
+
traceBus.addListener( 'entry', async ( ...args ) => {
|
|
31
|
+
try {
|
|
32
|
+
await p.exec( ...args );
|
|
33
|
+
} catch ( error ) {
|
|
34
|
+
console.error( `[Tracing] "${p.name}" processor execution error.`, error );
|
|
35
|
+
}
|
|
36
|
+
} );
|
|
29
37
|
}
|
|
30
38
|
};
|
|
31
39
|
|
|
@@ -6,11 +6,14 @@ import {
|
|
|
6
6
|
callExpression,
|
|
7
7
|
functionExpression,
|
|
8
8
|
identifier,
|
|
9
|
+
isArrowFunctionExpression,
|
|
9
10
|
isAssignmentPattern,
|
|
10
11
|
isBlockStatement,
|
|
11
12
|
isCallExpression,
|
|
12
13
|
isExportNamedDeclaration,
|
|
14
|
+
isFunctionExpression,
|
|
13
15
|
isIdentifier,
|
|
16
|
+
isVariableDeclarator,
|
|
14
17
|
isStringLiteral,
|
|
15
18
|
isVariableDeclaration,
|
|
16
19
|
isObjectExpression,
|
|
@@ -18,7 +21,8 @@ import {
|
|
|
18
21
|
returnStatement,
|
|
19
22
|
stringLiteral,
|
|
20
23
|
thisExpression,
|
|
21
|
-
isExportDefaultDeclaration
|
|
24
|
+
isExportDefaultDeclaration,
|
|
25
|
+
isFunctionDeclaration
|
|
22
26
|
} from '@babel/types';
|
|
23
27
|
import { ComponentFile, EXTRANEOUS_FILE, ExtraneousFileList, NodeType } from './consts.js';
|
|
24
28
|
|
|
@@ -170,6 +174,28 @@ export const getFileKind = path => {
|
|
|
170
174
|
export const createThisMethodCall = ( method, literalName, args ) =>
|
|
171
175
|
callExpression( memberExpression( thisExpression(), identifier( method ) ), [ stringLiteral( literalName ), ...args ] );
|
|
172
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Build a CallExpression that binds `this` at the call site:
|
|
179
|
+
* fn(arg1, arg2) -> fn.call(this, arg1, arg2)
|
|
180
|
+
*
|
|
181
|
+
* When to use:
|
|
182
|
+
* - Inside workflow `fn` rewriting, local call-chain functions must receive the dynamic `this`
|
|
183
|
+
* so that emitted `this.invokeStep(...)` and similar calls inside them operate correctly.
|
|
184
|
+
*
|
|
185
|
+
* Example:
|
|
186
|
+
* // Input AST intent:
|
|
187
|
+
* foo(a, b);
|
|
188
|
+
*
|
|
189
|
+
* // Rewritten AST:
|
|
190
|
+
* foo.call(this, a, b);
|
|
191
|
+
*
|
|
192
|
+
* @param {string} calleeName - Identifier name of the function being called (e.g., 'foo').
|
|
193
|
+
* @param {import('@babel/types').Expression[]} args - Original call arguments.
|
|
194
|
+
* @returns {import('@babel/types').CallExpression} CallExpression node representing `callee.call(this, ...args)`.
|
|
195
|
+
*/
|
|
196
|
+
export const bindThisAtCallSite = ( calleeName, args ) =>
|
|
197
|
+
callExpression( memberExpression( identifier( calleeName ), identifier( 'call' ) ), [ thisExpression(), ...args ] );
|
|
198
|
+
|
|
173
199
|
/**
|
|
174
200
|
* Resolve an options object's name property to a string.
|
|
175
201
|
* Accepts literal strings or top-level const string identifiers.
|
|
@@ -332,3 +358,93 @@ export const buildWorkflowNameMap = ( path, cache ) => {
|
|
|
332
358
|
cache.set( path, result );
|
|
333
359
|
return result;
|
|
334
360
|
};
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Determine whether a node represents a function body usable as a workflow `fn`.
|
|
364
|
+
*
|
|
365
|
+
* Why this matters:
|
|
366
|
+
* - Workflow `fn` needs a dynamic `this` so the rewriter can emit calls like `this.invokeStep(...)`.
|
|
367
|
+
* - Arrow functions do not have their own `this`; they capture `this` lexically, which breaks the runtime contract.
|
|
368
|
+
*
|
|
369
|
+
* Accepts:
|
|
370
|
+
* - FunctionExpression (possibly async/generator), e.g.:
|
|
371
|
+
* const obj = {
|
|
372
|
+
* fn: async function (input) {
|
|
373
|
+
* return input;
|
|
374
|
+
* }
|
|
375
|
+
* };
|
|
376
|
+
*
|
|
377
|
+
* Rejects:
|
|
378
|
+
* - ArrowFunctionExpression, e.g.:
|
|
379
|
+
* const obj = {
|
|
380
|
+
* fn: async (input) => input
|
|
381
|
+
* };
|
|
382
|
+
*
|
|
383
|
+
* - Any other non-function expression.
|
|
384
|
+
*
|
|
385
|
+
* Notes:
|
|
386
|
+
* - The rewriter will proactively convert arrow `fn` to a FunctionExpression before further processing.
|
|
387
|
+
*
|
|
388
|
+
* @param {import('@babel/types').Expression} v - Candidate node for `fn` value.
|
|
389
|
+
* @returns {boolean} True if `v` is a FunctionExpression and not an arrow function.
|
|
390
|
+
*/
|
|
391
|
+
export const isFunction = v => isFunctionExpression( v ) && !isArrowFunctionExpression( v );
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Determine whether a variable declarator represents a function-like value.
|
|
395
|
+
*
|
|
396
|
+
* Use case:
|
|
397
|
+
* - When `fn` calls a locally-declared function (directly or transitively), we need to:
|
|
398
|
+
* - propagate `this` to that function call (`callee.call(this, ...)`)
|
|
399
|
+
* - traverse into that function's body to rewrite imported step/workflow/evaluator calls.
|
|
400
|
+
*
|
|
401
|
+
* Matches patterns like:
|
|
402
|
+
* - Function expression:
|
|
403
|
+
* const foo = function (x) { return x + 1; };
|
|
404
|
+
*
|
|
405
|
+
* - Async/generator function expression:
|
|
406
|
+
* const foo = async function (x) { return await work(x); };
|
|
407
|
+
*
|
|
408
|
+
* - Arrow function (will be normalized to FunctionExpression by the rewriter):
|
|
409
|
+
* const foo = (x) => x + 1;
|
|
410
|
+
* const foo = async (x) => await work(x);
|
|
411
|
+
*
|
|
412
|
+
* Does not match:
|
|
413
|
+
* - Non-function initializers:
|
|
414
|
+
* const foo = 42;
|
|
415
|
+
* const foo = someIdentifier;
|
|
416
|
+
*
|
|
417
|
+
* @param {import('@babel/types').Node} v - AST node (typically a VariableDeclarator).
|
|
418
|
+
* @returns {boolean} True if the declarator's initializer is a function (arrow or function expression).
|
|
419
|
+
*/
|
|
420
|
+
export const isVarFunction = v =>
|
|
421
|
+
isVariableDeclarator( v ) && ( isFunctionExpression( v.init ) || isArrowFunctionExpression( v.init ) );
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Determine whether a binding node corresponds to a function-like declaration usable
|
|
425
|
+
* as a call-chain function target during workflow rewriting.
|
|
426
|
+
*
|
|
427
|
+
* Matches:
|
|
428
|
+
* - FunctionDeclaration:
|
|
429
|
+
* function foo(x) { return x + 1; }
|
|
430
|
+
*
|
|
431
|
+
* - VariableDeclarator initialized with a function or arrow (normalized later):
|
|
432
|
+
* const foo = function (x) { return x + 1; };
|
|
433
|
+
* const foo = (x) => x + 1;
|
|
434
|
+
*
|
|
435
|
+
* Non-matches:
|
|
436
|
+
* - Any binding that is not a function declaration nor a variable declarator with a function initializer.
|
|
437
|
+
*
|
|
438
|
+
* Why this matters:
|
|
439
|
+
* - The rewriter traverses call chains from the workflow `fn`. It must recognize which local
|
|
440
|
+
* callees are valid function bodies to rewrite and into which it can propagate `this`.
|
|
441
|
+
*
|
|
442
|
+
* @param {import('@babel/types').Node} node - Binding path node (FunctionDeclaration or VariableDeclarator).
|
|
443
|
+
* @returns {boolean} True if the node represents a function-like binding.
|
|
444
|
+
*/
|
|
445
|
+
export const isFunctionLikeBinding = node =>
|
|
446
|
+
isFunctionDeclaration( node ) ||
|
|
447
|
+
(
|
|
448
|
+
isVariableDeclarator( node ) &&
|
|
449
|
+
( isFunctionExpression( node.init ) || isArrowFunctionExpression( node.init ) )
|
|
450
|
+
);
|
|
@@ -12,24 +12,19 @@ function makeAst( source, filename ) {
|
|
|
12
12
|
describe( 'collect_target_imports', () => {
|
|
13
13
|
it( 'collects ESM imports for steps and workflows and flags changes', () => {
|
|
14
14
|
const dir = mkdtempSync( join( tmpdir(), 'collect-esm-' ) );
|
|
15
|
-
writeFileSync( join( dir, 'steps.js' ),
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
writeFileSync( join( dir, '
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
writeFileSync( join( dir, 'workflow.js' ), [
|
|
23
|
-
'export const FlowA = workflow({ name: "flow.a" })',
|
|
24
|
-
'export default workflow({ name: "flow.def" })'
|
|
25
|
-
].join( '\n' ) );
|
|
15
|
+
writeFileSync( join( dir, 'steps.js' ), `
|
|
16
|
+
export const StepA = step({ name: 'step.a' });
|
|
17
|
+
export const StepB = step({ name: 'step.b' });` );
|
|
18
|
+
writeFileSync( join( dir, 'evaluators.js' ), 'export const EvalA = evaluator({ name: \'eval.a\' });' );
|
|
19
|
+
writeFileSync( join( dir, 'workflow.js' ), `
|
|
20
|
+
export const FlowA = workflow({ name: 'flow.a' });
|
|
21
|
+
export default workflow({ name: 'flow.def' });` );
|
|
26
22
|
|
|
27
|
-
const source =
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
].join( '\n' );
|
|
23
|
+
const source = `
|
|
24
|
+
import { StepA } from './steps.js';
|
|
25
|
+
import { EvalA } from './evaluators.js';
|
|
26
|
+
import WF, { FlowA } from './workflow.js';
|
|
27
|
+
const x = 1;`;
|
|
33
28
|
|
|
34
29
|
const ast = makeAst( source, join( dir, 'file.js' ) );
|
|
35
30
|
const { stepImports, evaluatorImports, flowImports } = collectTargetImports(
|
|
@@ -52,16 +47,15 @@ describe( 'collect_target_imports', () => {
|
|
|
52
47
|
|
|
53
48
|
it( 'collects CJS requires and removes declarators (steps + default workflow)', () => {
|
|
54
49
|
const dir = mkdtempSync( join( tmpdir(), 'collect-cjs-' ) );
|
|
55
|
-
writeFileSync( join( dir, 'steps.js' ), 'export const StepB = step({ name:
|
|
56
|
-
writeFileSync( join( dir, 'evaluators.js' ), 'export const EvalB = evaluator({ name:
|
|
57
|
-
writeFileSync( join( dir, 'workflow.js' ), 'export default workflow({ name:
|
|
50
|
+
writeFileSync( join( dir, 'steps.js' ), 'export const StepB = step({ name: \'step.b\' })' );
|
|
51
|
+
writeFileSync( join( dir, 'evaluators.js' ), 'export const EvalB = evaluator({ name: \'eval.b\' })' );
|
|
52
|
+
writeFileSync( join( dir, 'workflow.js' ), 'export default workflow({ name: \'flow.c\' })' );
|
|
58
53
|
|
|
59
|
-
const source =
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
].join( '\n' );
|
|
54
|
+
const source = `
|
|
55
|
+
const { StepB } = require( './steps.js' );
|
|
56
|
+
const { EvalB } = require( './evaluators.js' );
|
|
57
|
+
const WF = require( './workflow.js' );
|
|
58
|
+
const obj = {};`;
|
|
65
59
|
|
|
66
60
|
const ast = makeAst( source, join( dir, 'file.js' ) );
|
|
67
61
|
const { stepImports, evaluatorImports, flowImports } = collectTargetImports(
|
|
@@ -19,25 +19,22 @@ function runLoader( source, resourcePath ) {
|
|
|
19
19
|
describe( 'workflows_rewriter Webpack loader spec', () => {
|
|
20
20
|
it( 'rewrites ESM imports and converts fn arrow to function', async () => {
|
|
21
21
|
const dir = mkdtempSync( join( tmpdir(), 'ast-loader-esm-' ) );
|
|
22
|
-
writeFileSync( join( dir, 'steps.js' ), 'export const StepA = step({ name: \'step.a\' })
|
|
23
|
-
writeFileSync( join( dir, 'workflow.js' ),
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
'}',
|
|
39
|
-
''
|
|
40
|
-
].join( '\n' );
|
|
22
|
+
writeFileSync( join( dir, 'steps.js' ), 'export const StepA = step({ name: \'step.a\' });' );
|
|
23
|
+
writeFileSync( join( dir, 'workflow.js' ), `
|
|
24
|
+
export const FlowA = workflow({ name: 'flow.a' });
|
|
25
|
+
export default workflow({ name: 'flow.def' });` );
|
|
26
|
+
|
|
27
|
+
const source = `
|
|
28
|
+
import { StepA } from './steps.js';
|
|
29
|
+
import FlowDef, { FlowA } from './workflow.js';
|
|
30
|
+
|
|
31
|
+
const obj = {
|
|
32
|
+
fn: async (x) => {
|
|
33
|
+
StepA(1);
|
|
34
|
+
FlowA(2);
|
|
35
|
+
FlowDef(3);
|
|
36
|
+
}
|
|
37
|
+
}`;
|
|
41
38
|
|
|
42
39
|
const { code } = await runLoader( source, join( dir, 'file.js' ) );
|
|
43
40
|
|
|
@@ -53,18 +50,16 @@ describe( 'workflows_rewriter Webpack loader spec', () => {
|
|
|
53
50
|
|
|
54
51
|
it( 'rewrites ESM shared_steps imports to invokeSharedStep', async () => {
|
|
55
52
|
const dir = mkdtempSync( join( tmpdir(), 'ast-loader-esm-shared-' ) );
|
|
56
|
-
writeFileSync( join( dir, 'shared_steps.js' ), 'export const SharedA = step({ name: \'shared.a\' })
|
|
57
|
-
|
|
58
|
-
const source =
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
''
|
|
67
|
-
].join( '\n' );
|
|
53
|
+
writeFileSync( join( dir, 'shared_steps.js' ), 'export const SharedA = step({ name: \'shared.a\' });' );
|
|
54
|
+
|
|
55
|
+
const source = `
|
|
56
|
+
import { SharedA } from './shared_steps.js';
|
|
57
|
+
|
|
58
|
+
const obj = {
|
|
59
|
+
fn: async (x) => {
|
|
60
|
+
SharedA(1);
|
|
61
|
+
}
|
|
62
|
+
}`;
|
|
68
63
|
|
|
69
64
|
const { code } = await runLoader( source, join( dir, 'file.js' ) );
|
|
70
65
|
|
|
@@ -77,18 +72,16 @@ describe( 'workflows_rewriter Webpack loader spec', () => {
|
|
|
77
72
|
|
|
78
73
|
it( 'rewrites CJS shared_steps requires to invokeSharedStep', async () => {
|
|
79
74
|
const dir = mkdtempSync( join( tmpdir(), 'ast-loader-cjs-shared-' ) );
|
|
80
|
-
writeFileSync( join( dir, 'shared_steps.js' ), 'export const SharedB = step({ name: \'shared.b\' })
|
|
81
|
-
|
|
82
|
-
const source =
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
''
|
|
91
|
-
].join( '\n' );
|
|
75
|
+
writeFileSync( join( dir, 'shared_steps.js' ), 'export const SharedB = step({ name: \'shared.b\' });' );
|
|
76
|
+
|
|
77
|
+
const source = `
|
|
78
|
+
const { SharedB } = require( './shared_steps.js' );
|
|
79
|
+
|
|
80
|
+
const obj = {
|
|
81
|
+
fn: async (y) => {
|
|
82
|
+
SharedB();
|
|
83
|
+
}
|
|
84
|
+
}`;
|
|
92
85
|
|
|
93
86
|
const { code } = await runLoader( source, join( dir, 'file.js' ) );
|
|
94
87
|
|
|
@@ -101,21 +94,19 @@ describe( 'workflows_rewriter Webpack loader spec', () => {
|
|
|
101
94
|
|
|
102
95
|
it( 'rewrites CJS requires and converts fn arrow to function', async () => {
|
|
103
96
|
const dir = mkdtempSync( join( tmpdir(), 'ast-loader-cjs-' ) );
|
|
104
|
-
writeFileSync( join( dir, 'steps.js' ), 'export const StepB = step({ name: \'step.b\' })
|
|
105
|
-
writeFileSync( join( dir, 'workflow.js' ), 'export default workflow({ name: \'flow.c\' })
|
|
106
|
-
|
|
107
|
-
const source =
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
''
|
|
118
|
-
].join( '\n' );
|
|
97
|
+
writeFileSync( join( dir, 'steps.js' ), 'export const StepB = step({ name: \'step.b\' });' );
|
|
98
|
+
writeFileSync( join( dir, 'workflow.js' ), 'export default workflow({ name: \'flow.c\' });' );
|
|
99
|
+
|
|
100
|
+
const source = `
|
|
101
|
+
const { StepB } = require( './steps.js' );
|
|
102
|
+
const FlowDefault = require( './workflow.js' );
|
|
103
|
+
|
|
104
|
+
const obj = {
|
|
105
|
+
fn: async (y) => {
|
|
106
|
+
StepB();
|
|
107
|
+
FlowDefault();
|
|
108
|
+
}
|
|
109
|
+
}`;
|
|
119
110
|
|
|
120
111
|
const { code } = await runLoader( source, join( dir, 'file.js' ) );
|
|
121
112
|
|
|
@@ -130,22 +121,19 @@ describe( 'workflows_rewriter Webpack loader spec', () => {
|
|
|
130
121
|
|
|
131
122
|
it( 'resolves top-level const name variables', async () => {
|
|
132
123
|
const dir = mkdtempSync( join( tmpdir(), 'ast-loader-const-' ) );
|
|
133
|
-
writeFileSync( join( dir, 'steps.js' ),
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
'import FlowDef, { FlowC } from \'./workflow.js\';',
|
|
147
|
-
'const obj = { fn: async () => { StepC(); FlowC(); FlowDef(); } }'
|
|
148
|
-
].join( '\n' );
|
|
124
|
+
writeFileSync( join( dir, 'steps.js' ), `
|
|
125
|
+
const NAME = 'step.const';
|
|
126
|
+
export const StepC = step({ name: NAME });` );
|
|
127
|
+
writeFileSync( join( dir, 'workflow.js' ), `
|
|
128
|
+
const WF = 'wf.const';
|
|
129
|
+
export const FlowC = workflow({ name: WF });
|
|
130
|
+
const D = 'wf.def';
|
|
131
|
+
export default workflow({ name: D });` );
|
|
132
|
+
|
|
133
|
+
const source = `
|
|
134
|
+
import { StepC } from './steps.js';
|
|
135
|
+
import FlowDef, { FlowC } from './workflow.js';
|
|
136
|
+
const obj = { fn: async () => { StepC(); FlowC(); FlowDef(); } }`;
|
|
149
137
|
|
|
150
138
|
const { code } = await runLoader( source, join( dir, 'file.js' ) );
|
|
151
139
|
expect( code ).toMatch( /this\.invokeStep\('step\.const'\)/ );
|
|
@@ -156,20 +144,17 @@ describe( 'workflows_rewriter Webpack loader spec', () => {
|
|
|
156
144
|
|
|
157
145
|
it( 'throws on non-static name', async () => {
|
|
158
146
|
const dir = mkdtempSync( join( tmpdir(), 'ast-loader-error-' ) );
|
|
159
|
-
writeFileSync( join( dir, 'steps.js' ),
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
'import WF from \'./workflow.js\';',
|
|
171
|
-
'const obj = { fn: async () => { StepX(); WF(); } }'
|
|
172
|
-
].join( '\n' );
|
|
147
|
+
writeFileSync( join( dir, 'steps.js' ), `
|
|
148
|
+
function n() { return 'x'; }
|
|
149
|
+
export const StepX = step({ name: n() });` );
|
|
150
|
+
writeFileSync( join( dir, 'workflow.js' ), `
|
|
151
|
+
const base = 'a';
|
|
152
|
+
export default workflow({ name: \`\${base}-b\` });` );
|
|
153
|
+
|
|
154
|
+
const source = `
|
|
155
|
+
import { StepX } from './steps.js';
|
|
156
|
+
import WF from './workflow.js';
|
|
157
|
+
const obj = { fn: async () => { StepX(); WF(); } }`;
|
|
173
158
|
|
|
174
159
|
await expect( runLoader( source, join( dir, 'file.js' ) ) ).rejects.toThrow( /Invalid (step|default workflow) name/ );
|
|
175
160
|
rmSync( dir, { recursive: true, force: true } );
|
|
@@ -1,10 +1,147 @@
|
|
|
1
1
|
import traverseModule from '@babel/traverse';
|
|
2
|
-
import { isArrowFunctionExpression, isIdentifier
|
|
3
|
-
import {
|
|
2
|
+
import { isArrowFunctionExpression, isIdentifier } from '@babel/types';
|
|
3
|
+
import {
|
|
4
|
+
toFunctionExpression,
|
|
5
|
+
createThisMethodCall,
|
|
6
|
+
isFunction,
|
|
7
|
+
bindThisAtCallSite,
|
|
8
|
+
isFunctionLikeBinding
|
|
9
|
+
} from '../tools.js';
|
|
4
10
|
|
|
5
11
|
// Handle CJS/ESM interop for Babel packages when executed as a webpack loader
|
|
6
12
|
const traverse = traverseModule.default ?? traverseModule;
|
|
7
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Check whether a CallExpression callee is a simple Identifier.
|
|
16
|
+
* Only direct identifier calls are rewritten; member/dynamic calls are skipped.
|
|
17
|
+
*
|
|
18
|
+
* We only support rewriting `Foo()` calls that refer to imported steps/flows/evaluators
|
|
19
|
+
* or local call-chain functions. Calls like `obj.Foo()` or `(getFn())()` are out of scope.
|
|
20
|
+
*
|
|
21
|
+
* Examples:
|
|
22
|
+
* - Supported: `Foo()`
|
|
23
|
+
* - Skipped: `obj.Foo()`, `(getFn())()`
|
|
24
|
+
*
|
|
25
|
+
* @param {import('@babel/traverse').NodePath} cPath - Path to a CallExpression node.
|
|
26
|
+
* @returns {boolean} True when callee is an Identifier.
|
|
27
|
+
*/
|
|
28
|
+
const isIdentifierCallee = cPath => isIdentifier( cPath.node.callee );
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Convert an ArrowFunctionExpression at the given path into a FunctionExpression
|
|
32
|
+
* to ensure dynamic `this` semantics inside the function body.
|
|
33
|
+
*
|
|
34
|
+
* Workflow code relies on `this` to invoke steps/flows (e.g., `this.invokeStep(...)`).
|
|
35
|
+
* Arrow functions capture `this` lexically, which would break that contract.
|
|
36
|
+
*
|
|
37
|
+
* If the node is an arrow, it is replaced by an equivalent FunctionExpression and
|
|
38
|
+
* the `state.rewrote` flag is set. If not an arrow, this is a no-op.
|
|
39
|
+
*
|
|
40
|
+
* @param {import('@babel/traverse').NodePath} nodePath - Path to a function node.
|
|
41
|
+
* @param {{ rewrote: boolean }} state - Mutation target to indicate a rewrite occurred.
|
|
42
|
+
* @returns {void}
|
|
43
|
+
*/
|
|
44
|
+
const normalizeArrowToFunctionPath = ( nodePath, state ) => {
|
|
45
|
+
if ( isArrowFunctionExpression( nodePath.node ) ) {
|
|
46
|
+
nodePath.replaceWith( toFunctionExpression( nodePath.node ) );
|
|
47
|
+
state.rewrote = true;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Rewrite calls inside a function body and collect call-chain functions discovered within.
|
|
52
|
+
* - Imported calls (steps/shared/evaluators/flows) are rewritten to `this.invokeX` or `this.startWorkflow`.
|
|
53
|
+
* - Local call-chain function calls are rewritten to `fn.call(this, ...)` to bind `this` correctly.
|
|
54
|
+
* - Returns a map of call-chain function name -> binding path for further recursive processing.
|
|
55
|
+
*
|
|
56
|
+
* @param {import('@babel/traverse').NodePath} bodyPath - Path to a function's body node.
|
|
57
|
+
* @param {Array<{ list: Array<any>, method: string, key: string }>} descriptors - Import rewrite descriptors.
|
|
58
|
+
* @param {{ rewrote: boolean }} state - Mutable state used to flag that edits were performed.
|
|
59
|
+
* @returns {Map<string, import('@babel/traverse').NodePath>} Discovered call-chain function bindings.
|
|
60
|
+
*/
|
|
61
|
+
const rewriteCallsInBody = ( bodyPath, descriptors, state ) => {
|
|
62
|
+
const callChainFunctions = new Map();
|
|
63
|
+
bodyPath.traverse( {
|
|
64
|
+
CallExpression: cPath => {
|
|
65
|
+
if ( !isIdentifierCallee( cPath ) ) {
|
|
66
|
+
return; // Only identifier callees are supported (skip member/dynamic)
|
|
67
|
+
}
|
|
68
|
+
const callee = cPath.node.callee;
|
|
69
|
+
|
|
70
|
+
// Rewrite imported calls (steps/shared/evaluators/flows)
|
|
71
|
+
for ( const { list, method, key } of descriptors ) {
|
|
72
|
+
const found = list.find( x => x.localName === callee.name );
|
|
73
|
+
if ( found ) {
|
|
74
|
+
const args = cPath.node.arguments;
|
|
75
|
+
cPath.replaceWith( createThisMethodCall( method, found[key], args ) );
|
|
76
|
+
state.rewrote = true;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Rewrite local call-chain function calls and track for recursive processing
|
|
82
|
+
const binding = cPath.scope.getBinding( callee.name );
|
|
83
|
+
if ( !binding ) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if ( !isFunctionLikeBinding( binding.path.node ) ) {
|
|
87
|
+
return; // Not a function-like binding
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Queue call-chain function for recursive processing
|
|
91
|
+
if ( !callChainFunctions.has( callee.name ) ) {
|
|
92
|
+
callChainFunctions.set( callee.name, binding.path );
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Bind `this` at callsite: fn(...) -> fn.call(this, ...)
|
|
96
|
+
cPath.replaceWith( bindThisAtCallSite( callee.name, cPath.node.arguments ) );
|
|
97
|
+
state.rewrote = true;
|
|
98
|
+
}
|
|
99
|
+
} );
|
|
100
|
+
return callChainFunctions;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Recursively process a call-chain function:
|
|
105
|
+
* - Ensures the function is a FunctionExpression (converts arrow when needed).
|
|
106
|
+
* - Rewrites calls inside the function using `rewriteCallsInBody`.
|
|
107
|
+
* - Follows nested call-chain functions depth-first while avoiding cycles via `processedFns`.
|
|
108
|
+
*
|
|
109
|
+
* @param {object} params - Params for processing a call-chain function.
|
|
110
|
+
* @param {string} params.name - Local identifier name in the current scope.
|
|
111
|
+
* @param {import('@babel/traverse').NodePath} params.bindingPath - Binding path of the function declaration.
|
|
112
|
+
* @param {{ rewrote: boolean }} params.state - Mutable state used to flag that edits were performed.
|
|
113
|
+
* @param {Array<{ list: Array<any>, method: string, key: string }>} params.descriptors - Import rewrite descriptors.
|
|
114
|
+
* @param {Set<string>} [params.processedFns] - Already processed names to avoid cycles.
|
|
115
|
+
*/
|
|
116
|
+
const processFunction = ( { name, bindingPath, state, descriptors, processedFns = new Set() } ) => {
|
|
117
|
+
// Avoid infinite loops for recursive/repeated references
|
|
118
|
+
if ( processedFns.has( name ) || bindingPath.removed ) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
processedFns.add( name );
|
|
122
|
+
|
|
123
|
+
if ( bindingPath.isVariableDeclarator() ) {
|
|
124
|
+
// Case 1: const foo = <function or arrow>
|
|
125
|
+
const initPath = bindingPath.get( 'init' );
|
|
126
|
+
// Arrow functions capture `this` lexically; normalize for dynamic `this`
|
|
127
|
+
normalizeArrowToFunctionPath( initPath, state );
|
|
128
|
+
// Rewrite calls in body; collect nested call-chain functions from this scope
|
|
129
|
+
const callChainFunctions = rewriteCallsInBody( initPath.get( 'body' ), descriptors, state );
|
|
130
|
+
// DFS: process nested call-chain functions (processedFns prevents cycles)
|
|
131
|
+
callChainFunctions.forEach( ( childBindingPath, childName ) => {
|
|
132
|
+
processFunction( { name: childName, bindingPath: childBindingPath, state, descriptors, processedFns } );
|
|
133
|
+
} );
|
|
134
|
+
} else if ( bindingPath.isFunctionDeclaration() ) {
|
|
135
|
+
// Case 2: function foo(...) { ... }
|
|
136
|
+
// Function declarations already have dynamic `this`; no normalization needed
|
|
137
|
+
const callChainFunctions = rewriteCallsInBody( bindingPath.get( 'body' ), descriptors, state );
|
|
138
|
+
// Continue DFS into any functions called from this declaration
|
|
139
|
+
callChainFunctions.forEach( ( childBindingPath, childName ) => {
|
|
140
|
+
processFunction( { name: childName, bindingPath: childBindingPath, state, descriptors, processedFns } );
|
|
141
|
+
} );
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
8
145
|
/**
|
|
9
146
|
* Rewrite calls to imported steps/workflows within `fn` object properties.
|
|
10
147
|
* Converts arrow fns to functions and replaces `StepX(...)` with
|
|
@@ -21,49 +158,36 @@ const traverse = traverseModule.default ?? traverseModule;
|
|
|
21
158
|
*/
|
|
22
159
|
export default function rewriteFnBodies( { ast, stepImports, sharedStepImports = [], evaluatorImports, flowImports } ) {
|
|
23
160
|
const state = { rewrote: false };
|
|
161
|
+
// Build rewrite descriptors once per traversal
|
|
162
|
+
const descriptors = [
|
|
163
|
+
{ list: stepImports, method: 'invokeStep', key: 'stepName' },
|
|
164
|
+
{ list: sharedStepImports, method: 'invokeSharedStep', key: 'stepName' },
|
|
165
|
+
{ list: evaluatorImports, method: 'invokeEvaluator', key: 'evaluatorName' },
|
|
166
|
+
{ list: flowImports, method: 'startWorkflow', key: 'workflowName' }
|
|
167
|
+
];
|
|
24
168
|
traverse( ast, {
|
|
25
169
|
ObjectProperty: path => {
|
|
26
|
-
//
|
|
170
|
+
// Only transform object properties named 'fn'
|
|
27
171
|
if ( !isIdentifier( path.node.key, { name: 'fn' } ) ) {
|
|
28
172
|
return;
|
|
29
173
|
}
|
|
30
174
|
|
|
31
175
|
const val = path.node.value;
|
|
32
176
|
|
|
33
|
-
//
|
|
34
|
-
if ( !
|
|
177
|
+
// Only functions (including arrows) are eligible
|
|
178
|
+
if ( !isFunction( val ) && !isArrowFunctionExpression( val ) ) {
|
|
35
179
|
return;
|
|
36
180
|
}
|
|
37
181
|
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
const func = toFunctionExpression( val );
|
|
41
|
-
path.get( 'value' ).replaceWith( func );
|
|
42
|
-
state.rewrote = true;
|
|
43
|
-
}
|
|
182
|
+
// Normalize arrow to function for correct dynamic `this`
|
|
183
|
+
normalizeArrowToFunctionPath( path.get( 'value' ), state );
|
|
44
184
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const descriptors = [
|
|
52
|
-
{ list: stepImports, method: 'invokeStep', key: 'stepName' },
|
|
53
|
-
{ list: sharedStepImports, method: 'invokeSharedStep', key: 'stepName' },
|
|
54
|
-
{ list: evaluatorImports, method: 'invokeEvaluator', key: 'evaluatorName' },
|
|
55
|
-
{ list: flowImports, method: 'startWorkflow', key: 'workflowName' }
|
|
56
|
-
];
|
|
57
|
-
for ( const { list, method, key } of descriptors ) {
|
|
58
|
-
const found = list.find( x => x.localName === callee.name );
|
|
59
|
-
if ( found ) {
|
|
60
|
-
const args = cPath.node.arguments;
|
|
61
|
-
cPath.replaceWith( createThisMethodCall( method, found[key], args ) );
|
|
62
|
-
state.rewrote = true;
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
}
|
|
185
|
+
// Rewrite the main workflow fn body and collect call-chain functions discovered within it
|
|
186
|
+
const callChainFunctions = rewriteCallsInBody( path.get( 'value.body' ), descriptors, state );
|
|
187
|
+
|
|
188
|
+
// Recursively rewrite call-chain functions and any functions they call
|
|
189
|
+
callChainFunctions.forEach( ( bindingPath, name ) => {
|
|
190
|
+
processFunction( { name, bindingPath, state, descriptors } );
|
|
67
191
|
} );
|
|
68
192
|
}
|
|
69
193
|
} );
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import generatorModule from '@babel/generator';
|
|
2
3
|
import { parse } from '../tools.js';
|
|
3
4
|
import rewriteFnBodies from './rewrite_fn_bodies.js';
|
|
4
5
|
|
|
6
|
+
const generate = generatorModule.default ?? generatorModule;
|
|
7
|
+
|
|
5
8
|
describe( 'rewrite_fn_bodies', () => {
|
|
6
9
|
it( 'converts arrow to function and rewrites step/workflow calls', () => {
|
|
7
|
-
const src =
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
].join( '\n' );
|
|
10
|
+
const src = `
|
|
11
|
+
const obj = {
|
|
12
|
+
fn: async x => {
|
|
13
|
+
StepA( 1 );
|
|
14
|
+
FlowB( 2 );
|
|
15
|
+
}
|
|
16
|
+
}`;
|
|
15
17
|
const ast = parse( src, 'file.js' );
|
|
16
18
|
const stepImports = [ { localName: 'StepA', stepName: 'step.a' } ];
|
|
17
19
|
const flowImports = [ { localName: 'FlowB', workflowName: 'flow.b' } ];
|
|
@@ -24,13 +26,12 @@ describe( 'rewrite_fn_bodies', () => {
|
|
|
24
26
|
} );
|
|
25
27
|
|
|
26
28
|
it( 'rewrites evaluator calls to this.invokeEvaluator', () => {
|
|
27
|
-
const src =
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
].join( '\n' );
|
|
29
|
+
const src = `
|
|
30
|
+
const obj = {
|
|
31
|
+
fn: async x => {
|
|
32
|
+
EvalA(3);
|
|
33
|
+
}
|
|
34
|
+
};`;
|
|
34
35
|
const ast = parse( src, 'file.js' );
|
|
35
36
|
const evaluatorImports = [ { localName: 'EvalA', evaluatorName: 'eval.a' } ];
|
|
36
37
|
const rewrote = rewriteFnBodies( { ast, stepImports: [], evaluatorImports, flowImports: [] } );
|
|
@@ -43,5 +44,80 @@ describe( 'rewrite_fn_bodies', () => {
|
|
|
43
44
|
const rewrote = rewriteFnBodies( { ast, stepImports: [], evaluatorImports: [], flowImports: [] } );
|
|
44
45
|
expect( rewrote ).toBe( false );
|
|
45
46
|
} );
|
|
47
|
+
|
|
48
|
+
it( 'rewrites helper calls and helper bodies (steps and evaluators)', () => {
|
|
49
|
+
const src = `
|
|
50
|
+
const foo = async () => {
|
|
51
|
+
StepA( 1 );
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function bar( x ) {
|
|
55
|
+
EvalA( x );
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const obj = {
|
|
59
|
+
fn: async x => {
|
|
60
|
+
foo();
|
|
61
|
+
bar( 2 );
|
|
62
|
+
}
|
|
63
|
+
}`;
|
|
64
|
+
|
|
65
|
+
const ast = parse( src, 'file.js' );
|
|
66
|
+
const stepImports = [ { localName: 'StepA', stepName: 'step.a' } ];
|
|
67
|
+
const evaluatorImports = [ { localName: 'EvalA', evaluatorName: 'eval.a' } ];
|
|
68
|
+
|
|
69
|
+
const rewrote = rewriteFnBodies( { ast, stepImports, sharedStepImports: [], evaluatorImports, flowImports: [] } );
|
|
70
|
+
expect( rewrote ).toBe( true );
|
|
71
|
+
|
|
72
|
+
const { code } = generate( ast, { quotes: 'single' } );
|
|
73
|
+
|
|
74
|
+
// Helper calls in fn are rewritten to call(this, ...)
|
|
75
|
+
expect( code ).toMatch( /foo\.call\(this\)/ );
|
|
76
|
+
expect( code ).toMatch( /bar\.call\(this,\s*2\)/ );
|
|
77
|
+
|
|
78
|
+
// Inside helpers, calls are rewritten
|
|
79
|
+
expect( code ).toMatch( /this\.invokeStep\(([\"'])step\.a\1,\s*1\)/ );
|
|
80
|
+
expect( code ).toMatch( /this\.invokeEvaluator\(([\"'])eval\.a\1,\s*x\)/ );
|
|
81
|
+
|
|
82
|
+
// Arrow helper converted to function expression to allow dynamic this
|
|
83
|
+
expect( code ).toMatch( /const foo = async function/ );
|
|
84
|
+
} );
|
|
85
|
+
|
|
86
|
+
it( 'rewrites nested helper chains until the step invocation', () => {
|
|
87
|
+
const src = `
|
|
88
|
+
const foo = () => {
|
|
89
|
+
bar();
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
function bar() {
|
|
93
|
+
baz( 42 );
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const baz = n => {
|
|
97
|
+
StepA( n );
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const obj = {
|
|
101
|
+
fn: async () => {
|
|
102
|
+
foo();
|
|
103
|
+
}
|
|
104
|
+
}`;
|
|
105
|
+
|
|
106
|
+
const ast = parse( src, 'file.js' );
|
|
107
|
+
const stepImports = [ { localName: 'StepA', stepName: 'step.a' } ];
|
|
108
|
+
const rewrote = rewriteFnBodies( { ast, stepImports, evaluatorImports: [], flowImports: [] } );
|
|
109
|
+
expect( rewrote ).toBe( true );
|
|
110
|
+
|
|
111
|
+
const { code } = generate( ast, { quotes: 'single' } );
|
|
112
|
+
// Calls along the chain are bound with this
|
|
113
|
+
expect( code ).toMatch( /foo\.call\(this\)/ );
|
|
114
|
+
expect( code ).toMatch( /bar\.call\(this\)/ );
|
|
115
|
+
expect( code ).toMatch( /baz\.call\(this,\s*42\)/ );
|
|
116
|
+
// Deep step rewrite in the last helper
|
|
117
|
+
expect( code ).toMatch( /this\.invokeStep\(([\"'])step\.a\1,\s*n\)/ );
|
|
118
|
+
// Arrow helpers converted to functions
|
|
119
|
+
expect( code ).toMatch( /const foo = function/ );
|
|
120
|
+
expect( code ).toMatch( /const baz = function/ );
|
|
121
|
+
} );
|
|
46
122
|
} );
|
|
47
123
|
|