@output.ai/core 0.0.7 → 0.0.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 +85 -59
- package/package.json +10 -3
- package/src/configs.js +1 -1
- package/src/consts.js +4 -3
- package/src/errors.js +11 -0
- package/src/index.d.ts +302 -30
- package/src/index.js +3 -2
- package/src/interface/metadata.js +3 -3
- package/src/interface/step.js +18 -4
- package/src/interface/utils.js +41 -4
- package/src/interface/utils.spec.js +71 -0
- package/src/interface/validations/ajv_provider.js +3 -0
- package/src/interface/validations/runtime.js +69 -0
- package/src/interface/validations/runtime.spec.js +50 -0
- package/src/interface/validations/static.js +67 -0
- package/src/interface/validations/static.spec.js +101 -0
- package/src/interface/webhook.js +15 -14
- package/src/interface/workflow.js +45 -40
- package/src/internal_activities/index.js +16 -5
- package/src/worker/catalog_workflow/catalog.js +105 -0
- package/src/worker/catalog_workflow/index.js +21 -0
- package/src/worker/catalog_workflow/index.spec.js +139 -0
- package/src/worker/catalog_workflow/workflow.js +13 -0
- package/src/worker/index.js +41 -5
- package/src/worker/interceptors/activity.js +3 -2
- package/src/worker/internal_utils.js +60 -0
- package/src/worker/internal_utils.spec.js +134 -0
- package/src/worker/loader.js +30 -44
- package/src/worker/loader.spec.js +68 -0
- package/src/worker/sinks.js +2 -1
- package/src/worker/tracer/index.js +35 -3
- package/src/worker/tracer/index.test.js +115 -0
- package/src/worker/tracer/tracer_tree.js +29 -5
- package/src/worker/tracer/tracer_tree.test.js +116 -0
- package/src/worker/webpack_loaders/workflow_rewriter/collect_target_imports.js +133 -0
- package/src/worker/webpack_loaders/workflow_rewriter/collect_target_imports.spec.js +77 -0
- package/src/worker/webpack_loaders/workflow_rewriter/consts.js +3 -0
- package/src/worker/webpack_loaders/workflow_rewriter/index.mjs +58 -0
- package/src/worker/webpack_loaders/workflow_rewriter/index.spec.js +129 -0
- package/src/worker/webpack_loaders/workflow_rewriter/rewrite_fn_bodies.js +70 -0
- package/src/worker/webpack_loaders/workflow_rewriter/rewrite_fn_bodies.spec.js +33 -0
- package/src/worker/webpack_loaders/workflow_rewriter/tools.js +245 -0
- package/src/worker/webpack_loaders/workflow_rewriter/tools.spec.js +144 -0
- package/src/errors.d.ts +0 -3
- package/src/worker/temp/__workflows_entrypoint.js +0 -6
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { dirname } from 'node:path';
|
|
2
|
+
import generatorModule from '@babel/generator';
|
|
3
|
+
import { parse } from './tools.js';
|
|
4
|
+
|
|
5
|
+
import rewriteFnBodies from './rewrite_fn_bodies.js';
|
|
6
|
+
import collectTargetImports from './collect_target_imports.js';
|
|
7
|
+
|
|
8
|
+
// Handle CJS/ESM interop for Babel packages when executed as a webpack loader
|
|
9
|
+
const generate = generatorModule.default ?? generatorModule;
|
|
10
|
+
|
|
11
|
+
// Caches to avoid re-reading files during a build
|
|
12
|
+
const stepsNameCache = new Map(); // path -> Map<exported, stepName>
|
|
13
|
+
const workflowNameCache = new Map(); // path -> { default?: name, named: Map<exported, flowName> }
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Webpack loader that rewrites step/workflow calls by reading names from
|
|
17
|
+
* the respective modules and transforming `fn` bodies accordingly.
|
|
18
|
+
* Preserves sourcemaps.
|
|
19
|
+
*
|
|
20
|
+
* @param {string|Buffer} source - Module source code.
|
|
21
|
+
* @param {any} inputMap - Incoming source map.
|
|
22
|
+
* @this {import('webpack').LoaderContext<{}>}
|
|
23
|
+
* @returns {void}
|
|
24
|
+
*/
|
|
25
|
+
export default function stepImportRewriterAstLoader( source, inputMap ) {
|
|
26
|
+
this.cacheable?.( true );
|
|
27
|
+
const callback = this.async?.() ?? this.callback;
|
|
28
|
+
const cache = { stepsNameCache, workflowNameCache };
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const filename = this.resourcePath;
|
|
32
|
+
const ast = parse( String( source ), filename );
|
|
33
|
+
const fileDir = dirname( filename );
|
|
34
|
+
const { stepImports, flowImports } = collectTargetImports( ast, fileDir, cache );
|
|
35
|
+
|
|
36
|
+
// No imports
|
|
37
|
+
if ( stepImports.length + flowImports.length === 0 ) {
|
|
38
|
+
return callback( null, source, inputMap );
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const rewrote = rewriteFnBodies( ast, stepImports, flowImports );
|
|
42
|
+
// No edits performed
|
|
43
|
+
if ( !rewrote ) {
|
|
44
|
+
return callback( null, source, inputMap );
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const { code, map } = generate( ast, {
|
|
48
|
+
sourceMaps: true,
|
|
49
|
+
sourceFileName: filename,
|
|
50
|
+
quotes: 'single',
|
|
51
|
+
jsescOption: { quotes: 'single' }
|
|
52
|
+
}, String( source ) );
|
|
53
|
+
return callback( null, code, map ?? inputMap );
|
|
54
|
+
} catch ( err ) {
|
|
55
|
+
// Fail gracefully as loader error
|
|
56
|
+
return callback( err );
|
|
57
|
+
}
|
|
58
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import loader from './index.mjs';
|
|
6
|
+
|
|
7
|
+
function runLoader( source, resourcePath ) {
|
|
8
|
+
return new Promise( ( resolve, reject ) => {
|
|
9
|
+
const ctx = {
|
|
10
|
+
resourcePath,
|
|
11
|
+
cacheable: () => {},
|
|
12
|
+
async: () => ( err, code, map ) => ( err ? reject( err ) : resolve( { code, map } ) ),
|
|
13
|
+
callback: ( err, code, map ) => ( err ? reject( err ) : resolve( { code, map } ) )
|
|
14
|
+
};
|
|
15
|
+
loader.call( ctx, source, null );
|
|
16
|
+
} );
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe( 'workflows_rewriter Webpack loader spec', () => {
|
|
20
|
+
it( 'rewrites ESM imports and converts fn arrow to function', async () => {
|
|
21
|
+
const dir = mkdtempSync( join( tmpdir(), 'ast-loader-esm-' ) );
|
|
22
|
+
writeFileSync( join( dir, 'steps.js' ), 'export const StepA = step({ name: \'step.a\' })\n' );
|
|
23
|
+
writeFileSync( join( dir, 'workflow.js' ), [
|
|
24
|
+
'export const FlowA = workflow({ name: \'flow.a\' })',
|
|
25
|
+
'export default workflow({ name: \'flow.def\' })'
|
|
26
|
+
].join( '\n' ) );
|
|
27
|
+
|
|
28
|
+
const source = [
|
|
29
|
+
'import { StepA } from \'./steps.js\';',
|
|
30
|
+
'import FlowDef, { FlowA } from \'./workflow.js\';',
|
|
31
|
+
'',
|
|
32
|
+
'const obj = {',
|
|
33
|
+
' fn: async (x) => {',
|
|
34
|
+
' StepA(1);',
|
|
35
|
+
' FlowA(2);',
|
|
36
|
+
' FlowDef(3);',
|
|
37
|
+
' }',
|
|
38
|
+
'}',
|
|
39
|
+
''
|
|
40
|
+
].join( '\n' );
|
|
41
|
+
|
|
42
|
+
const { code } = await runLoader( source, join( dir, 'file.js' ) );
|
|
43
|
+
|
|
44
|
+
expect( code ).not.toMatch( /from '\.\/steps\.js'/ );
|
|
45
|
+
expect( code ).not.toMatch( /from '\.\/workflow\.js'/ );
|
|
46
|
+
expect( code ).toMatch( /fn:\s*async function \(x\)/ );
|
|
47
|
+
expect( code ).toMatch( /this\.invokeStep\('step\.a',\s*1\)/ );
|
|
48
|
+
expect( code ).toMatch( /this\.startWorkflow\('flow\.a',\s*2\)/ );
|
|
49
|
+
expect( code ).toMatch( /this\.startWorkflow\('flow\.def',\s*3\)/ );
|
|
50
|
+
|
|
51
|
+
rmSync( dir, { recursive: true, force: true } );
|
|
52
|
+
} );
|
|
53
|
+
|
|
54
|
+
it( 'rewrites CJS requires and converts fn arrow to function', async () => {
|
|
55
|
+
const dir = mkdtempSync( join( tmpdir(), 'ast-loader-cjs-' ) );
|
|
56
|
+
writeFileSync( join( dir, 'steps.js' ), 'export const StepB = step({ name: \'step.b\' })\n' );
|
|
57
|
+
writeFileSync( join( dir, 'workflow.js' ), 'export default workflow({ name: \'flow.c\' })\n' );
|
|
58
|
+
|
|
59
|
+
const source = [
|
|
60
|
+
'const { StepB } = require(\'./steps.js\');',
|
|
61
|
+
'const FlowDefault = require(\'./workflow.js\');',
|
|
62
|
+
'',
|
|
63
|
+
'const obj = {',
|
|
64
|
+
' fn: async (y) => {',
|
|
65
|
+
' StepB();',
|
|
66
|
+
' FlowDefault();',
|
|
67
|
+
' }',
|
|
68
|
+
'}',
|
|
69
|
+
''
|
|
70
|
+
].join( '\n' );
|
|
71
|
+
|
|
72
|
+
const { code } = await runLoader( source, join( dir, 'file.js' ) );
|
|
73
|
+
|
|
74
|
+
expect( code ).not.toMatch( /require\('\.\/steps\.js'\)/ );
|
|
75
|
+
expect( code ).not.toMatch( /require\('\.\/workflow\.js'\)/ );
|
|
76
|
+
expect( code ).toMatch( /fn:\s*async function \(y\)/ );
|
|
77
|
+
expect( code ).toMatch( /this\.invokeStep\('step\.b'\)/ );
|
|
78
|
+
expect( code ).toMatch( /this\.startWorkflow\('flow\.c'\)/ );
|
|
79
|
+
|
|
80
|
+
rmSync( dir, { recursive: true, force: true } );
|
|
81
|
+
} );
|
|
82
|
+
|
|
83
|
+
it( 'resolves top-level const name variables', async () => {
|
|
84
|
+
const dir = mkdtempSync( join( tmpdir(), 'ast-loader-const-' ) );
|
|
85
|
+
writeFileSync( join( dir, 'steps.js' ), [
|
|
86
|
+
'const NAME = \'step.const\'',
|
|
87
|
+
'export const StepC = step({ name: NAME })'
|
|
88
|
+
].join( '\n' ) );
|
|
89
|
+
writeFileSync( join( dir, 'workflow.js' ), [
|
|
90
|
+
'const WF = \'wf.const\'',
|
|
91
|
+
'export const FlowC = workflow({ name: WF })',
|
|
92
|
+
'const D = \'wf.def\'',
|
|
93
|
+
'export default workflow({ name: D })'
|
|
94
|
+
].join( '\n' ) );
|
|
95
|
+
|
|
96
|
+
const source = [
|
|
97
|
+
'import { StepC } from \'./steps.js\';',
|
|
98
|
+
'import FlowDef, { FlowC } from \'./workflow.js\';',
|
|
99
|
+
'const obj = { fn: async () => { StepC(); FlowC(); FlowDef(); } }'
|
|
100
|
+
].join( '\n' );
|
|
101
|
+
|
|
102
|
+
const { code } = await runLoader( source, join( dir, 'file.js' ) );
|
|
103
|
+
expect( code ).toMatch( /this\.invokeStep\('step\.const'\)/ );
|
|
104
|
+
expect( code ).toMatch( /this\.startWorkflow\('wf\.const'\)/ );
|
|
105
|
+
expect( code ).toMatch( /this\.startWorkflow\('wf\.def'\)/ );
|
|
106
|
+
rmSync( dir, { recursive: true, force: true } );
|
|
107
|
+
} );
|
|
108
|
+
|
|
109
|
+
it( 'throws on non-static name', async () => {
|
|
110
|
+
const dir = mkdtempSync( join( tmpdir(), 'ast-loader-error-' ) );
|
|
111
|
+
writeFileSync( join( dir, 'steps.js' ), [
|
|
112
|
+
'function n() { return \'x\' }',
|
|
113
|
+
'export const StepX = step({ name: n() })'
|
|
114
|
+
].join( '\n' ) );
|
|
115
|
+
writeFileSync( join( dir, 'workflow.js' ), [
|
|
116
|
+
'const base = \'a\'',
|
|
117
|
+
'export default workflow({ name: `${base}-b` })'
|
|
118
|
+
].join( '\n' ) );
|
|
119
|
+
|
|
120
|
+
const source = [
|
|
121
|
+
'import { StepX } from \'./steps.js\';',
|
|
122
|
+
'import WF from \'./workflow.js\';',
|
|
123
|
+
'const obj = { fn: async () => { StepX(); WF(); } }'
|
|
124
|
+
].join( '\n' );
|
|
125
|
+
|
|
126
|
+
await expect( runLoader( source, join( dir, 'file.js' ) ) ).rejects.toThrow( /Invalid (step|default workflow) name/ );
|
|
127
|
+
rmSync( dir, { recursive: true, force: true } );
|
|
128
|
+
} );
|
|
129
|
+
} );
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import traverseModule from '@babel/traverse';
|
|
2
|
+
import {
|
|
3
|
+
isArrowFunctionExpression,
|
|
4
|
+
isIdentifier,
|
|
5
|
+
isFunctionExpression
|
|
6
|
+
} from '@babel/types';
|
|
7
|
+
import { toFunctionExpression, createThisMethodCall } from './tools.js';
|
|
8
|
+
|
|
9
|
+
// Handle CJS/ESM interop for Babel packages when executed as a webpack loader
|
|
10
|
+
const traverse = traverseModule.default ?? traverseModule;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Rewrite calls to imported steps/workflows within `fn` object properties.
|
|
14
|
+
* Converts arrow fns to functions and replaces `StepX(...)` with
|
|
15
|
+
* `this.invokeStep('name', ...)` and `FlowY(...)` with
|
|
16
|
+
* `this.startWorkflow('name', ...)`.
|
|
17
|
+
*
|
|
18
|
+
* @param {import('@babel/types').File} ast - Parsed file AST.
|
|
19
|
+
* @param {Array<{localName:string,stepName:string}>} stepImports - Step imports.
|
|
20
|
+
* @param {Array<{localName:string,workflowName:string}>} flowImports - Workflow imports.
|
|
21
|
+
* @returns {boolean} True if the AST was modified; false otherwise.
|
|
22
|
+
*/
|
|
23
|
+
export default function rewriteFnBodies( ast, stepImports, flowImports ) {
|
|
24
|
+
const state = { rewrote: false };
|
|
25
|
+
traverse( ast, {
|
|
26
|
+
ObjectProperty: path => {
|
|
27
|
+
// If it is not fn property: skip
|
|
28
|
+
if ( !isIdentifier( path.node.key, { name: 'fn' } ) ) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const val = path.node.value;
|
|
33
|
+
|
|
34
|
+
// if it is not function, return
|
|
35
|
+
if ( !isFunctionExpression( val ) && !isArrowFunctionExpression( val ) ) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// replace arrow fn in favor of a function
|
|
40
|
+
if ( isArrowFunctionExpression( val ) ) {
|
|
41
|
+
const func = toFunctionExpression( val );
|
|
42
|
+
path.get( 'value' ).replaceWith( func );
|
|
43
|
+
state.rewrote = true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
path.get( 'value.body' ).traverse( {
|
|
47
|
+
CallExpression: cPath => {
|
|
48
|
+
const callee = cPath.node.callee;
|
|
49
|
+
if ( !isIdentifier( callee ) ) {
|
|
50
|
+
return;
|
|
51
|
+
} // Skip: complex callee not supported
|
|
52
|
+
const step = stepImports.find( x => x.localName === callee.name );
|
|
53
|
+
if ( step ) {
|
|
54
|
+
const args = cPath.node.arguments;
|
|
55
|
+
cPath.replaceWith( createThisMethodCall( 'invokeStep', step.stepName, args ) );
|
|
56
|
+
state.rewrote = true;
|
|
57
|
+
return; // Stop after rewriting as step call
|
|
58
|
+
}
|
|
59
|
+
const flow = flowImports.find( x => x.localName === callee.name );
|
|
60
|
+
if ( flow ) {
|
|
61
|
+
const args = cPath.node.arguments;
|
|
62
|
+
cPath.replaceWith( createThisMethodCall( 'startWorkflow', flow.workflowName, args ) );
|
|
63
|
+
state.rewrote = true;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} );
|
|
67
|
+
}
|
|
68
|
+
} );
|
|
69
|
+
return state.rewrote;
|
|
70
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parse } from './tools.js';
|
|
3
|
+
import rewriteFnBodies from './rewrite_fn_bodies.js';
|
|
4
|
+
|
|
5
|
+
describe( 'rewrite_fn_bodies', () => {
|
|
6
|
+
it( 'converts arrow to function and rewrites step/workflow calls', () => {
|
|
7
|
+
const src = [
|
|
8
|
+
'const obj = {',
|
|
9
|
+
' fn: async (x) => {',
|
|
10
|
+
' StepA(1);',
|
|
11
|
+
' FlowB(2);',
|
|
12
|
+
' }',
|
|
13
|
+
'}'
|
|
14
|
+
].join( '\n' );
|
|
15
|
+
const ast = parse( src, 'file.js' );
|
|
16
|
+
const stepImports = [ { localName: 'StepA', stepName: 'step.a' } ];
|
|
17
|
+
const flowImports = [ { localName: 'FlowB', workflowName: 'flow.b' } ];
|
|
18
|
+
|
|
19
|
+
const rewrote = rewriteFnBodies( ast, stepImports, flowImports );
|
|
20
|
+
expect( rewrote ).toBe( true );
|
|
21
|
+
|
|
22
|
+
const code = ast.program.body.map( n => n.type ).length; // smoke: ast mutated
|
|
23
|
+
expect( code ).toBeGreaterThan( 0 );
|
|
24
|
+
} );
|
|
25
|
+
|
|
26
|
+
it( 'does nothing when no matching calls are present', () => {
|
|
27
|
+
const src = [ 'const obj = { fn: function() { other(); } }' ].join( '\n' );
|
|
28
|
+
const ast = parse( src, 'file.js' );
|
|
29
|
+
const rewrote = rewriteFnBodies( ast, [], [] );
|
|
30
|
+
expect( rewrote ).toBe( false );
|
|
31
|
+
} );
|
|
32
|
+
} );
|
|
33
|
+
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import parser from '@babel/parser';
|
|
2
|
+
import { resolve as resolvePath } from 'node:path';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import {
|
|
5
|
+
blockStatement,
|
|
6
|
+
callExpression,
|
|
7
|
+
functionExpression,
|
|
8
|
+
identifier,
|
|
9
|
+
isAssignmentPattern,
|
|
10
|
+
isBlockStatement,
|
|
11
|
+
isCallExpression,
|
|
12
|
+
isExportNamedDeclaration,
|
|
13
|
+
isIdentifier,
|
|
14
|
+
isStringLiteral,
|
|
15
|
+
isVariableDeclaration,
|
|
16
|
+
isObjectExpression,
|
|
17
|
+
memberExpression,
|
|
18
|
+
returnStatement,
|
|
19
|
+
stringLiteral,
|
|
20
|
+
thisExpression,
|
|
21
|
+
isExportDefaultDeclaration
|
|
22
|
+
} from '@babel/types';
|
|
23
|
+
import { NodeType } from './consts.js';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve a relative module specifier against a base directory.
|
|
27
|
+
* @param {string} fileDir - Base directory to resolve from.
|
|
28
|
+
* @param {string} rel - Relative path/specifier.
|
|
29
|
+
* @returns {string} Absolute path.
|
|
30
|
+
*/
|
|
31
|
+
export const toAbsolutePath = ( fileDir, rel ) => resolvePath( fileDir, rel );
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parse JavaScript/TypeScript source into a Babel AST.
|
|
35
|
+
* @param {string} source - Source code to parse.
|
|
36
|
+
* @param {string} filename - Virtual filename for sourcemaps and diagnostics.
|
|
37
|
+
* @returns {import('@babel/types').File} Parsed AST.
|
|
38
|
+
*/
|
|
39
|
+
export const parse = ( source, filename ) => parser.parse( source, {
|
|
40
|
+
sourceType: 'module',
|
|
41
|
+
sourceFilename: filename,
|
|
42
|
+
plugins: [ 'jsx' ]
|
|
43
|
+
} );
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Extract top-level constant string bindings (e.g., const NAME = 'value').
|
|
47
|
+
* @param {import('@babel/types').File} ast - Parsed file AST.
|
|
48
|
+
* @returns {Map<string, string>} Map from identifier to string literal value.
|
|
49
|
+
*/
|
|
50
|
+
export const extractTopLevelStringConsts = ast =>
|
|
51
|
+
ast.program.body
|
|
52
|
+
.filter( node => isVariableDeclaration( node ) && node.kind === NodeType.CONST )
|
|
53
|
+
.reduce( ( map, node ) => {
|
|
54
|
+
node.declarations
|
|
55
|
+
.filter( dec => isIdentifier( dec.id ) && isStringLiteral( dec.init ) )
|
|
56
|
+
.forEach( dec => map.set( dec.id.name, dec.init.value ) );
|
|
57
|
+
return map;
|
|
58
|
+
}, new Map() );
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve an object key name from an Identifier or StringLiteral.
|
|
62
|
+
* @param {import('@babel/types').Expression} node - Object key node.
|
|
63
|
+
* @returns {string|null} Key name or null when unsupported.
|
|
64
|
+
*/
|
|
65
|
+
export const getObjectKeyName = node => {
|
|
66
|
+
if ( isIdentifier( node ) ) {
|
|
67
|
+
return node.name;
|
|
68
|
+
}
|
|
69
|
+
if ( isStringLiteral( node ) ) {
|
|
70
|
+
return node.value;
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Extract the local identifier name from a destructured ObjectProperty.
|
|
77
|
+
* Supports: { a } and { a: local } and { a: local = default }.
|
|
78
|
+
* @param {import('@babel/types').ObjectProperty} prop - Object property.
|
|
79
|
+
* @returns {string|null} Local identifier name or null.
|
|
80
|
+
*/
|
|
81
|
+
export const getLocalNameFromDestructuredProperty = prop => {
|
|
82
|
+
if ( isIdentifier( prop.value ) ) {
|
|
83
|
+
return prop.value.name;
|
|
84
|
+
}
|
|
85
|
+
if ( isAssignmentPattern( prop.value ) && isIdentifier( prop.value.left ) ) {
|
|
86
|
+
return prop.value.left.name;
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Convert an ArrowFunctionExpression to a FunctionExpression.
|
|
93
|
+
* Wraps expression bodies in a block with a return statement.
|
|
94
|
+
* @param {import('@babel/types').ArrowFunctionExpression} arrow - Arrow function.
|
|
95
|
+
* @returns {import('@babel/types').FunctionExpression} Function expression.
|
|
96
|
+
*/
|
|
97
|
+
export const toFunctionExpression = arrow => {
|
|
98
|
+
const body = isBlockStatement( arrow.body ) ? arrow.body : blockStatement( [ returnStatement( arrow.body ) ] );
|
|
99
|
+
return functionExpression( null, arrow.params, body, arrow.generator ?? false, arrow.async ?? false );
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Check if a module specifier or request string points to steps.js.
|
|
104
|
+
* @param {string} value - Module path or request string.
|
|
105
|
+
* @returns {boolean} True if it matches steps.js.
|
|
106
|
+
*/
|
|
107
|
+
export const isStepsPath = value => /(^|\/)steps\.js$/.test( value );
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Check if a module specifier or request string points to workflow.js.
|
|
111
|
+
* @param {string} value - Module path or request string.
|
|
112
|
+
* @returns {boolean} True if it matches workflow.js.
|
|
113
|
+
*/
|
|
114
|
+
export const isWorkflowPath = value => /(^|\/)workflow\.js$/.test( value );
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Create a `this.method(literalName, ...args)` CallExpression.
|
|
118
|
+
* @param {string} method - Method name on `this`.
|
|
119
|
+
* @param {string} literalName - First string literal argument.
|
|
120
|
+
* @param {import('@babel/types').Expression[]} args - Remaining call arguments.
|
|
121
|
+
* @returns {import('@babel/types').CallExpression} Call expression node.
|
|
122
|
+
*/
|
|
123
|
+
export const createThisMethodCall = ( method, literalName, args ) =>
|
|
124
|
+
callExpression( memberExpression( thisExpression(), identifier( method ) ), [ stringLiteral( literalName ), ...args ] );
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Resolve an options object's name property to a string.
|
|
128
|
+
* Accepts literal strings or top-level const string identifiers.
|
|
129
|
+
* @param {import('@babel/types').Expression} optionsNode - The call options object.
|
|
130
|
+
* @param {Map<string,string>} consts - Top-level const string bindings.
|
|
131
|
+
* @param {string} errorMessagePrefix - Prefix used when throwing validation errors.
|
|
132
|
+
* @returns {string} Resolved name.
|
|
133
|
+
* @throws {Error} When name is missing or not a supported static form.
|
|
134
|
+
*/
|
|
135
|
+
export const resolveNameFromOptions = ( optionsNode, consts, errorMessagePrefix ) => {
|
|
136
|
+
// If it is not an object
|
|
137
|
+
if ( !isObjectExpression( optionsNode ) ) {
|
|
138
|
+
throw new Error( `${errorMessagePrefix}: Missing properties` );
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Look specifically for the 'name' property
|
|
142
|
+
for ( const prop of optionsNode.properties ) {
|
|
143
|
+
if ( getObjectKeyName( prop.key ) !== 'name' ) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const val = prop.value;
|
|
148
|
+
// if it is a string literal: jackpot
|
|
149
|
+
if ( isStringLiteral( val ) ) {
|
|
150
|
+
return val.value;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// if it is an identifier, it needs to be deterministic (top-level const)
|
|
154
|
+
if ( isIdentifier( val ) ) {
|
|
155
|
+
if ( consts.has( val.name ) ) {
|
|
156
|
+
return consts.get( val.name );
|
|
157
|
+
}
|
|
158
|
+
throw new Error( `${errorMessagePrefix}: Name identifier "${val.name}" is not a top-level const string` );
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
throw new Error( `${errorMessagePrefix}: Name must be a string literal or a top-level const string` );
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
throw new Error( `${errorMessagePrefix}: Missing required name property` ); // No name field found
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Build a map from exported step variable name to declared step name.
|
|
169
|
+
* Parses the steps module and extracts `step({ name: '...' })` names.
|
|
170
|
+
* @param {string} path - Absolute path to the steps module file.
|
|
171
|
+
* @param {Map<string, Map<string,string>>} cache - Cache of computed step name maps.
|
|
172
|
+
* @returns {Map<string,string>} Exported identifier -> step name.
|
|
173
|
+
* @throws {Error} When a step name is invalid (non-static or missing).
|
|
174
|
+
*/
|
|
175
|
+
export const buildStepsNameMap = ( path, cache ) => {
|
|
176
|
+
if ( cache.has( path ) ) {
|
|
177
|
+
return cache.get( path );
|
|
178
|
+
}
|
|
179
|
+
const text = readFileSync( path, 'utf8' );
|
|
180
|
+
const ast = parse( text, path );
|
|
181
|
+
const consts = extractTopLevelStringConsts( ast );
|
|
182
|
+
|
|
183
|
+
const stepMap = ast.program.body
|
|
184
|
+
.filter( node => isExportNamedDeclaration( node ) && isVariableDeclaration( node.declaration ) )
|
|
185
|
+
.reduce( ( map, node ) => {
|
|
186
|
+
|
|
187
|
+
node.declaration.declarations
|
|
188
|
+
.filter( dec => isIdentifier( dec.id ) && isCallExpression( dec.init ) && isIdentifier( dec.init.callee, { name: 'step' } ) )
|
|
189
|
+
.map( dec => [
|
|
190
|
+
dec,
|
|
191
|
+
resolveNameFromOptions( dec.init.arguments[0], consts, `Invalid step name in ${path} for "${dec.id.name}"` )
|
|
192
|
+
] )
|
|
193
|
+
.forEach( ( [ dec, name ] ) => map.set( dec.id.name, name ) );
|
|
194
|
+
return map;
|
|
195
|
+
}, new Map() );
|
|
196
|
+
|
|
197
|
+
cache.set( path, stepMap );
|
|
198
|
+
return stepMap;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Build a structure with default and named workflow names from a workflow module.
|
|
203
|
+
* Extracts names from `workflow({ name: '...' })` calls.
|
|
204
|
+
* @param {string} path - Absolute path to the workflow module file.
|
|
205
|
+
* @param {Map<string, {default: (string|null), named: Map<string,string>}>} cache - Cache of workflow names.
|
|
206
|
+
* @returns {{ default: (string|null), named: Map<string,string> }} Names.
|
|
207
|
+
* @throws {Error} When a workflow name is invalid (non-static or missing).
|
|
208
|
+
*/
|
|
209
|
+
export const buildWorkflowNameMap = ( path, cache ) => {
|
|
210
|
+
if ( cache.has( path ) ) {
|
|
211
|
+
return cache.get( path );
|
|
212
|
+
}
|
|
213
|
+
const text = readFileSync( path, 'utf8' );
|
|
214
|
+
const ast = parse( text, path );
|
|
215
|
+
const consts = extractTopLevelStringConsts( ast );
|
|
216
|
+
|
|
217
|
+
const result = { default: null, named: new Map() };
|
|
218
|
+
|
|
219
|
+
for ( const node of ast.program.body ) {
|
|
220
|
+
|
|
221
|
+
// named exports
|
|
222
|
+
if ( isExportNamedDeclaration( node ) && isVariableDeclaration( node.declaration ) ) {
|
|
223
|
+
|
|
224
|
+
for ( const d of node.declaration.declarations ) {
|
|
225
|
+
if ( isIdentifier( d.id ) && isCallExpression( d.init ) && isIdentifier( d.init.callee, { name: 'workflow' } ) ) {
|
|
226
|
+
const name = resolveNameFromOptions( d.init.arguments[0], consts, `Invalid workflow name in ${path} for '${d.id.name}` );
|
|
227
|
+
if ( name ) {
|
|
228
|
+
result.named.set( d.id.name, name );
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// default exports
|
|
234
|
+
} else if (
|
|
235
|
+
isExportDefaultDeclaration( node ) &&
|
|
236
|
+
isCallExpression( node.declaration ) &&
|
|
237
|
+
isIdentifier( node.declaration.callee, { name: 'workflow' } )
|
|
238
|
+
) {
|
|
239
|
+
result.default = resolveNameFromOptions( node.declaration.arguments[0], consts, `Invalid default workflow name in ${path}` );
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
cache.set( path, result );
|
|
244
|
+
return result;
|
|
245
|
+
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join, resolve as resolvePath } from 'node:path';
|
|
5
|
+
import * as t from '@babel/types';
|
|
6
|
+
import {
|
|
7
|
+
toAbsolutePath,
|
|
8
|
+
parse,
|
|
9
|
+
extractTopLevelStringConsts,
|
|
10
|
+
getObjectKeyName,
|
|
11
|
+
getLocalNameFromDestructuredProperty,
|
|
12
|
+
toFunctionExpression,
|
|
13
|
+
isStepsPath,
|
|
14
|
+
isWorkflowPath,
|
|
15
|
+
createThisMethodCall,
|
|
16
|
+
resolveNameFromOptions,
|
|
17
|
+
buildStepsNameMap,
|
|
18
|
+
buildWorkflowNameMap
|
|
19
|
+
} from './tools.js';
|
|
20
|
+
|
|
21
|
+
describe( 'workflow_rewriter tools', () => {
|
|
22
|
+
it( 'parse: parses JS with JSX plugin enabled', () => {
|
|
23
|
+
const ast = parse( 'const A = 1; const C = () => <div />', 'file.js' );
|
|
24
|
+
expect( ast?.type ).toBe( 'File' );
|
|
25
|
+
expect( ast.program.body.length ).toBeGreaterThan( 0 );
|
|
26
|
+
} );
|
|
27
|
+
|
|
28
|
+
it( 'toAbsolutePath: resolves relative path against base directory', () => {
|
|
29
|
+
expect( toAbsolutePath( '/base/dir', './file.js' ) ).toBe( resolvePath( '/base/dir', './file.js' ) );
|
|
30
|
+
} );
|
|
31
|
+
|
|
32
|
+
it( 'extractTopLevelStringConsts: returns only const string bindings', () => {
|
|
33
|
+
const ast = parse( [
|
|
34
|
+
'const A = \"a\"; let B = \"b\"; const C = 3;',
|
|
35
|
+
'const D = `d`; const E = \"e\"'
|
|
36
|
+
].join( '\n' ), 'file.js' );
|
|
37
|
+
const map = extractTopLevelStringConsts( ast );
|
|
38
|
+
expect( map.get( 'A' ) ).toBe( 'a' );
|
|
39
|
+
expect( map.has( 'B' ) ).toBe( false );
|
|
40
|
+
expect( map.has( 'C' ) ).toBe( false );
|
|
41
|
+
// Template literal is not a StringLiteral
|
|
42
|
+
expect( map.has( 'D' ) ).toBe( false );
|
|
43
|
+
expect( map.get( 'E' ) ).toBe( 'e' );
|
|
44
|
+
} );
|
|
45
|
+
|
|
46
|
+
it( 'resolveNameFromOptions: returns literal name from options object', () => {
|
|
47
|
+
const opts = t.objectExpression( [ t.objectProperty( t.identifier( 'name' ), t.stringLiteral( 'literal.name' ) ) ] );
|
|
48
|
+
const out = resolveNameFromOptions( opts, new Map(), 'X' );
|
|
49
|
+
expect( out ).toBe( 'literal.name' );
|
|
50
|
+
} );
|
|
51
|
+
|
|
52
|
+
it( 'getObjectKeyName: resolves from Identifier and StringLiteral', () => {
|
|
53
|
+
expect( getObjectKeyName( t.identifier( 'name' ) ) ).toBe( 'name' );
|
|
54
|
+
expect( getObjectKeyName( t.stringLiteral( 'x' ) ) ).toBe( 'x' );
|
|
55
|
+
expect( getObjectKeyName( t.numericLiteral( 1 ) ) ).toBeNull();
|
|
56
|
+
} );
|
|
57
|
+
|
|
58
|
+
it( 'buildStepsNameMap: reads names from steps module and caches result', () => {
|
|
59
|
+
const dir = mkdtempSync( join( tmpdir(), 'tools-steps-' ) );
|
|
60
|
+
const stepsPath = join( dir, 'steps.js' );
|
|
61
|
+
writeFileSync( stepsPath, [
|
|
62
|
+
'export const StepA = step({ name: "step.a" })',
|
|
63
|
+
'export const StepB = step({ name: "step.b" })'
|
|
64
|
+
].join( '\n' ) );
|
|
65
|
+
const cache = new Map();
|
|
66
|
+
const map1 = buildStepsNameMap( stepsPath, cache );
|
|
67
|
+
expect( map1.get( 'StepA' ) ).toBe( 'step.a' );
|
|
68
|
+
expect( map1.get( 'StepB' ) ).toBe( 'step.b' );
|
|
69
|
+
expect( cache.get( stepsPath ) ).toBe( map1 );
|
|
70
|
+
const map2 = buildStepsNameMap( stepsPath, cache );
|
|
71
|
+
expect( map2 ).toBe( map1 );
|
|
72
|
+
rmSync( dir, { recursive: true, force: true } );
|
|
73
|
+
} );
|
|
74
|
+
|
|
75
|
+
it( 'getLocalNameFromDestructuredProperty: handles { a }, { a: b }, { a: b = 1 }', () => {
|
|
76
|
+
// { a }
|
|
77
|
+
const p1 = t.objectProperty( t.identifier( 'a' ), t.identifier( 'a' ), false, true );
|
|
78
|
+
expect( getLocalNameFromDestructuredProperty( p1 ) ).toBe( 'a' );
|
|
79
|
+
// { a: b }
|
|
80
|
+
const p2 = t.objectProperty( t.identifier( 'a' ), t.identifier( 'b' ) );
|
|
81
|
+
expect( getLocalNameFromDestructuredProperty( p2 ) ).toBe( 'b' );
|
|
82
|
+
// { a: b = 1 }
|
|
83
|
+
const p3 = t.objectProperty( t.identifier( 'a' ), t.assignmentPattern( t.identifier( 'b' ), t.numericLiteral( 1 ) ) );
|
|
84
|
+
expect( getLocalNameFromDestructuredProperty( p3 ) ).toBe( 'b' );
|
|
85
|
+
// Unsupported shape
|
|
86
|
+
const p4 = t.objectProperty( t.identifier( 'a' ), t.arrayExpression( [] ) );
|
|
87
|
+
expect( getLocalNameFromDestructuredProperty( p4 ) ).toBeNull();
|
|
88
|
+
} );
|
|
89
|
+
|
|
90
|
+
it( 'buildWorkflowNameMap: reads named and default workflow names and caches', () => {
|
|
91
|
+
const dir = mkdtempSync( join( tmpdir(), 'tools-flow-' ) );
|
|
92
|
+
const wfPath = join( dir, 'workflow.js' );
|
|
93
|
+
writeFileSync( wfPath, [
|
|
94
|
+
'export const FlowA = workflow({ name: "flow.a" })',
|
|
95
|
+
'export default workflow({ name: "flow.def" })'
|
|
96
|
+
].join( '\n' ) );
|
|
97
|
+
const cache = new Map();
|
|
98
|
+
const res1 = buildWorkflowNameMap( wfPath, cache );
|
|
99
|
+
expect( res1.named.get( 'FlowA' ) ).toBe( 'flow.a' );
|
|
100
|
+
expect( res1.default ).toBe( 'flow.def' );
|
|
101
|
+
expect( cache.get( wfPath ) ).toBe( res1 );
|
|
102
|
+
const res2 = buildWorkflowNameMap( wfPath, cache );
|
|
103
|
+
expect( res2 ).toBe( res1 );
|
|
104
|
+
rmSync( dir, { recursive: true, force: true } );
|
|
105
|
+
} );
|
|
106
|
+
|
|
107
|
+
it( 'toFunctionExpression: converts arrow, wraps expression bodies', () => {
|
|
108
|
+
const arrowExprBody = t.arrowFunctionExpression( [ t.identifier( 'x' ) ], t.identifier( 'x' ) );
|
|
109
|
+
const arrowBlockBody = t.arrowFunctionExpression( [], t.blockStatement( [ t.returnStatement( t.numericLiteral( 1 ) ) ] ) );
|
|
110
|
+
const fn1 = toFunctionExpression( arrowExprBody );
|
|
111
|
+
const fn2 = toFunctionExpression( arrowBlockBody );
|
|
112
|
+
expect( t.isFunctionExpression( fn1 ) ).toBe( true );
|
|
113
|
+
expect( t.isBlockStatement( fn1.body ) ).toBe( true );
|
|
114
|
+
expect( t.isReturnStatement( fn1.body.body[0] ) ).toBe( true );
|
|
115
|
+
expect( t.isFunctionExpression( fn2 ) ).toBe( true );
|
|
116
|
+
} );
|
|
117
|
+
|
|
118
|
+
it( 'isStepsPath: matches steps.js at root or subpath', () => {
|
|
119
|
+
expect( isStepsPath( 'steps.js' ) ).toBe( true );
|
|
120
|
+
expect( isStepsPath( './steps.js' ) ).toBe( true );
|
|
121
|
+
expect( isStepsPath( '/a/b/steps.js' ) ).toBe( true );
|
|
122
|
+
expect( isStepsPath( 'steps.ts' ) ).toBe( false );
|
|
123
|
+
expect( isStepsPath( 'workflow.js' ) ).toBe( false );
|
|
124
|
+
} );
|
|
125
|
+
|
|
126
|
+
it( 'isWorkflowPath: matches workflow.js at root or subpath', () => {
|
|
127
|
+
expect( isWorkflowPath( 'workflow.js' ) ).toBe( true );
|
|
128
|
+
expect( isWorkflowPath( './workflow.js' ) ).toBe( true );
|
|
129
|
+
expect( isWorkflowPath( '/a/b/workflow.js' ) ).toBe( true );
|
|
130
|
+
expect( isWorkflowPath( 'workflow.ts' ) ).toBe( false );
|
|
131
|
+
expect( isWorkflowPath( 'steps.js' ) ).toBe( false );
|
|
132
|
+
} );
|
|
133
|
+
|
|
134
|
+
it( 'createThisMethodCall: builds this.method(\'name\', ...args) call', () => {
|
|
135
|
+
const call = createThisMethodCall( 'invoke', 'n', [ t.numericLiteral( 1 ), t.identifier( 'x' ) ] );
|
|
136
|
+
expect( t.isCallExpression( call ) ).toBe( true );
|
|
137
|
+
expect( t.isMemberExpression( call.callee ) ).toBe( true );
|
|
138
|
+
expect( t.isThisExpression( call.callee.object ) ).toBe( true );
|
|
139
|
+
expect( t.isIdentifier( call.callee.property, { name: 'invoke' } ) ).toBe( true );
|
|
140
|
+
expect( t.isStringLiteral( call.arguments[0], { value: 'n' } ) ).toBe( true );
|
|
141
|
+
expect( call.arguments.length ).toBe( 3 );
|
|
142
|
+
} );
|
|
143
|
+
} );
|
|
144
|
+
|
package/src/errors.d.ts
DELETED