diff --git a/integration-tests/cli/test/errors.test.ts b/integration-tests/cli/test/errors.test.ts index c9742dd47..4bef066e5 100644 --- a/integration-tests/cli/test/errors.test.ts +++ b/integration-tests/cli/test/errors.test.ts @@ -2,10 +2,17 @@ import test from 'ava'; import path from 'node:path'; import run from '../src/run'; import { extractLogs, assertLog } from '../src/util'; -import { stderr } from 'node:process'; const jobsPath = path.resolve('test/fixtures'); +const extractErrorLogs = (stdout) => { + const stdlogs = extractLogs(stdout); + return stdlogs + .filter((e) => e.level === 'error') + .map((e) => e.message.join(' ').replace(/\(\d+ms\)/, '(SSSms)')) + .filter((e) => e.length); +}; + // These are all errors that will stop the CLI from even running test.serial('expression not found', async (t) => { @@ -13,7 +20,6 @@ test.serial('expression not found', async (t) => { t.is(err.code, 1); const stdlogs = extractLogs(stdout); - assertLog(t, stdlogs, /expression not found/i); assertLog(t, stdlogs, /failed to load the expression from blah.js/i); assertLog(t, stdlogs, /critical error: aborting command/i); @@ -133,3 +139,65 @@ test.serial('invalid end (ambiguous)', async (t) => { assertLog(t, stdlogs, /Error: end pattern matched multiple steps/i); assertLog(t, stdlogs, /aborting/i); }); + +// These test error outputs within valid workflows + +test.serial('job with reference error', async (t) => { + const { stdout, err } = await run( + `openfn ${jobsPath}/errors.json --log-json --start ref --no-cache-steps` + ); + + const logs = extractErrorLogs(stdout); + t.log(logs); + + t.deepEqual(logs, [ + 'ref aborted with error (SSSms)', + `TypeError: Cannot read properties of undefined (reading 'y') + at vm:module(0):1:23 + @openfn/language-common_2.1.1/dist/index.cjs:333:12`, + 'Error occurred at: ref', + '1: fn((state) => state.x.y)', + ' ^ ', + 'Check state.errors.ref for details', + ]); +}); + +test.serial('job with not a function error', async (t) => { + const { stdout, err } = await run( + `openfn ${jobsPath}/errors.json --log-json --start not-function --no-cache-steps` + ); + + const logs = extractErrorLogs(stdout); + t.log(logs); + + t.deepEqual(logs, [ + 'not-function aborted with error (SSSms)', + `TypeError: state is not a function + at vm:module(0):1:15 + @openfn/language-common_2.1.1/dist/index.cjs:333:12`, + 'Error occurred at: not-function', + '1: fn((state) => state())', + ' ^ ', + 'Check state.errors.not-function for details', + ]); +}); + +test.serial('job with assign-to-const error', async (t) => { + const { stdout, err } = await run( + `openfn ${jobsPath}/errors.json --log-json --start assign-const --no-cache-steps` + ); + + const logs = extractErrorLogs(stdout); + t.log(logs); + + t.deepEqual(logs, [ + 'assign-const aborted with error (SSSms)', + `TypeError: Assignment to constant variable. + at vm:module(0):1:33 + @openfn/language-common_2.1.1/dist/index.cjs:333:12`, + 'Error occurred at: assign-const', + '1: fn((state) => { const x = 10; x = 20; })', + ' ^ ', + 'Check state.errors.assign-const for details', + ]); +}); diff --git a/integration-tests/cli/test/fixtures/errors.json b/integration-tests/cli/test/fixtures/errors.json new file mode 100644 index 000000000..cfc9d35fc --- /dev/null +++ b/integration-tests/cli/test/fixtures/errors.json @@ -0,0 +1,21 @@ +{ + "workflow": { + "steps": [ + { + "id": "ref", + "adaptor": "common", + "expression": "fn((state) => state.x.y)" + }, + { + "id": "not-function", + "adaptor": "common", + "expression": "fn((state) => state())" + }, + { + "id": "assign-const", + "adaptor": "common", + "expression": "fn((state) => { const x = 10; x = 20; })" + } + ] + } +} diff --git a/integration-tests/execute/CHANGELOG.md b/integration-tests/execute/CHANGELOG.md index 1a9061514..209c95db3 100644 --- a/integration-tests/execute/CHANGELOG.md +++ b/integration-tests/execute/CHANGELOG.md @@ -1,5 +1,16 @@ # @openfn/integration-tests-execute +## 1.0.13 + +### Patch Changes + +- Updated dependencies [4ddd4d6] +- Updated dependencies [93e9844] +- Updated dependencies [aaa7e7b] +- Updated dependencies [4ddd4d6] + - @openfn/runtime@1.6.0 + - @openfn/compiler@1.0.0 + ## 1.0.12 ### Patch Changes diff --git a/integration-tests/execute/package.json b/integration-tests/execute/package.json index 40abc19ee..308b83932 100644 --- a/integration-tests/execute/package.json +++ b/integration-tests/execute/package.json @@ -1,7 +1,7 @@ { "name": "@openfn/integration-tests-execute", "private": true, - "version": "1.0.12", + "version": "1.0.13", "description": "Job execution tests", "author": "Open Function Group ", "license": "ISC", diff --git a/integration-tests/execute/src/execute.ts b/integration-tests/execute/src/execute.ts index b4fd40f12..f19ba57cb 100644 --- a/integration-tests/execute/src/execute.ts +++ b/integration-tests/execute/src/execute.ts @@ -2,10 +2,16 @@ import path from 'node:path'; import run from '@openfn/runtime'; import compiler from '@openfn/compiler'; -const execute = async (job: string, state: any, adaptor = 'common') => { +const execute = async ( + job: string, + state: any, + adaptor = 'common', + ignore = [] +) => { // compile with common and dumb imports const options = { 'add-imports': { + ignore, adaptors: [ { name: `@openfn/language-${adaptor}`, @@ -15,9 +21,8 @@ const execute = async (job: string, state: any, adaptor = 'common') => { }, }; const compiled = compiler(job, options); - // console.log(compiled); - - const result = await run(compiled, state, { + const result = await run(compiled.code, state, { + sourceMap: compiled.map, // preload the linker with some locally installed modules linker: { modules: { diff --git a/integration-tests/execute/test/errors.test.ts b/integration-tests/execute/test/errors.test.ts new file mode 100644 index 000000000..511ae644f --- /dev/null +++ b/integration-tests/execute/test/errors.test.ts @@ -0,0 +1,72 @@ +import test from 'ava'; +import execute from '../src/execute'; + +// This tests the raw errors that come out of runtime +// (or are written to state) +// How those errors are serialized, displayed and emitted +// is a problem for the runtime managers (which can have their own tests) + +test.serial('should throw a reference error', async (t) => { + const state = { data: { x: 1 } }; + + const job = `fn((s) => x)`; + + // Tell the compiler not to import x from the adaptor + const ignore = ['x']; + + let err; + try { + await execute(job, state, 'common', ignore); + } catch (e: any) { + err = e; + } + + t.is(err.message, 'ReferenceError: x is not defined'); + t.is(err.severity, 'crash'); + t.is(err.step, 'job-1'); // this name is auto-generated btw + t.deepEqual(err.pos, { + column: 11, + line: 1, + src: 'fn((s) => x)', + }); +}); + +test.serial('should throw a type error', async (t) => { + const state = { data: { x: 1 } }; + + const job = `fn((s) => s())`; + + // Tell the compiler not to import x from the adaptor + const ignore = ['x']; + + const result = await execute(job, state, 'common', ignore); + const err = result.errors['job-1']; + t.log(err.pos); + + t.is(err.message, 'TypeError: s is not a function'); + t.is(err.severity, 'fail'); + t.is(err.step, 'job-1'); + t.deepEqual(err.pos, { + column: 11, + line: 1, + src: 'fn((s) => s())', + }); +}); + +// In http 6.4.3 this throws a type error +// because the start of the error message is TypeError +// In 6.5 we get a better error out +// But the real question is: is AdaptorError even a useful error class? +// I think it confuses people +test.serial.skip('should throw an adaptor error', async (t) => { + const state = { data: { x: 1 } }; + + const job = `fn((s) => s) +get("www")`; + + const result = await execute(job, state, 'http'); + const err = result.errors['job-1']; + t.log(err); + + t.is(err.message, 'AdaptorError: INVALID_URL'); +}); diff --git a/integration-tests/worker/CHANGELOG.md b/integration-tests/worker/CHANGELOG.md index d607691ab..7a72c1a36 100644 --- a/integration-tests/worker/CHANGELOG.md +++ b/integration-tests/worker/CHANGELOG.md @@ -1,5 +1,16 @@ # @openfn/integration-tests-worker +## 1.0.73 + +### Patch Changes + +- Updated dependencies [4ddd4d6] +- Updated dependencies [6e87156] + - @openfn/ws-worker@1.9.0 + - @openfn/logger@1.0.4 + - @openfn/engine-multi@1.4.7 + - @openfn/lightning-mock@2.0.28 + ## 1.0.72 ### Patch Changes diff --git a/integration-tests/worker/package.json b/integration-tests/worker/package.json index f448a4b66..66da41e9a 100644 --- a/integration-tests/worker/package.json +++ b/integration-tests/worker/package.json @@ -1,7 +1,7 @@ { "name": "@openfn/integration-tests-worker", "private": true, - "version": "1.0.72", + "version": "1.0.73", "description": "Lightning WOrker integration tests", "author": "Open Function Group ", "license": "ISC", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 0185a3a3f..ff8dd16fd 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,23 @@ # @openfn/cli +## 1.10.0 + +### Minor Changes + +- 4ddd4d6: Update errors to include a source-mapped position and more dignostic information + +### Patch Changes + +- Updated dependencies [4ddd4d6] +- Updated dependencies [93e9844] +- Updated dependencies [6e87156] +- Updated dependencies [aaa7e7b] +- Updated dependencies [4ddd4d6] + - @openfn/runtime@1.6.0 + - @openfn/compiler@1.0.0 + - @openfn/logger@1.0.4 + - @openfn/deploy@0.8.2 + ## 1.9.1 ### Patch Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 5eb0633f9..b440b75c6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openfn/cli", - "version": "1.9.1", + "version": "1.10.0", "description": "CLI devtools for the openfn toolchain.", "engines": { "node": ">=18", diff --git a/packages/cli/src/compile/compile.ts b/packages/cli/src/compile/compile.ts index 413a7057e..9a7818047 100644 --- a/packages/cli/src/compile/compile.ts +++ b/packages/cli/src/compile/compile.ts @@ -1,36 +1,56 @@ import compile, { preloadAdaptorExports, Options } from '@openfn/compiler'; import { getModulePath } from '@openfn/runtime'; -import type { ExecutionPlan, Job } from '@openfn/lexicon'; +import type { + ExecutionPlan, + Job, + SourceMapWithOperations, +} from '@openfn/lexicon'; import createLogger, { COMPILER, Logger } from '../util/logger'; import abort from '../util/abort'; import type { CompileOptions } from './command'; -// Load and compile a job from a file, then return the result -// This is designed to be re-used in different CLI steps -export default async ( - planOrPath: ExecutionPlan | string, +export type CompiledJob = { code: string; map?: SourceMapWithOperations }; + +export default async function ( + job: ExecutionPlan, opts: CompileOptions, log: Logger -) => { +): Promise; + +export default async function ( + plan: string, + opts: CompileOptions, + log: Logger +): Promise; + +export default async function ( + planOrPath: string | ExecutionPlan, + opts: CompileOptions, + log: Logger +): Promise { if (typeof planOrPath === 'string') { const result = await compileJob(planOrPath as string, opts, log); log.success(`Compiled expression from ${opts.expressionPath}`); return result; } - const compiledPlan = compileWorkflow(planOrPath as ExecutionPlan, opts, log); + const compiledPlan = await compileWorkflow( + planOrPath as ExecutionPlan, + opts, + log + ); log.success('Compiled all expressions in workflow'); return compiledPlan; -}; +} const compileJob = async ( job: string, opts: CompileOptions, log: Logger, jobName?: string -): Promise => { +): Promise => { try { const compilerOptions: Options = await loadTransformOptions(opts, log); return compile(job, compilerOptions); @@ -41,8 +61,8 @@ const compileJob = async ( e, 'Check the syntax of the job expression:\n\n' + job ); - // This will never actully execute - return ''; + // This will never actually execute + return { code: job }; } }; @@ -59,12 +79,14 @@ const compileWorkflow = async ( adaptors: job.adaptors ?? opts.adaptors, }; if (job.expression) { - job.expression = await compileJob( + const { code, map } = await compileJob( job.expression as string, jobOpts, log, job.id ); + job.expression = code; + job.sourceMap = map; } } return plan; diff --git a/packages/cli/src/compile/handler.ts b/packages/cli/src/compile/handler.ts index ac19752fb..c92c62f80 100644 --- a/packages/cli/src/compile/handler.ts +++ b/packages/cli/src/compile/handler.ts @@ -11,11 +11,12 @@ const compileHandler = async (options: CompileOptions, logger: Logger) => { let result; if (options.expressionPath) { - result = await compile(options.expressionPath, options, logger); + const { code } = await compile(options.expressionPath, options, logger); + result = code; } else { const plan = await loadPlan(options, logger); - result = await compile(plan, options, logger); - result = JSON.stringify(result, null, 2); + const compiledPlan = await compile(plan, options, logger); + result = JSON.stringify(compiledPlan, null, 2); } if (options.outputStdout) { diff --git a/packages/cli/src/execute/handler.ts b/packages/cli/src/execute/handler.ts index 294e3c1c9..e0abf6628 100644 --- a/packages/cli/src/execute/handler.ts +++ b/packages/cli/src/execute/handler.ts @@ -105,7 +105,7 @@ const executeHandler = async (options: ExecuteOptions, logger: Logger) => { const state = await loadState(plan, options, logger, customStart); if (options.compile) { - plan = (await compile(plan, options, logger)) as ExecutionPlan; + plan = await compile(plan, options, logger); } else { logger.info('Skipping compilation as noCompile is set'); } diff --git a/packages/cli/test/compile/compile.test.ts b/packages/cli/test/compile/compile.test.ts index a4b1dcb5d..af5b5556b 100644 --- a/packages/cli/test/compile/compile.test.ts +++ b/packages/cli/test/compile/compile.test.ts @@ -2,6 +2,7 @@ import test from 'ava'; import mock from 'mock-fs'; import path from 'node:path'; import { createMockLogger } from '@openfn/logger'; + import compile, { stripVersionSpecifier, loadTransformOptions, @@ -9,7 +10,8 @@ import compile, { } from '../../src/compile/compile'; import { CompileOptions } from '../../src/compile/command'; import { mockFs, resetMockFs } from '../util'; -import { ExecutionPlan, Job } from '@openfn/lexicon'; + +import type { ExecutionPlan, Job } from '@openfn/lexicon'; const mockLog = createMockLogger(); @@ -27,18 +29,15 @@ type TransformOptionsWithImports = { }; }; -// TODO this isn't really used and is a bit of a quirky thing -// The compiler itself probably doesn't do any path parsing? -// Just compile a source string and return the result -test('compile from source string', async (t) => { +test.serial('compile from source string', async (t) => { const job = 'x();'; const opts = {} as CompileOptions; - const result = await compile(job, opts, mockLog); + const result = await compile(job, opts, mockLog) const expected = 'export default [x()];'; - t.is(result, expected); + t.is(result.code, expected); }); test.serial('compile from path', async (t) => { @@ -54,10 +53,10 @@ test.serial('compile from path', async (t) => { const result = await compile(expressionPath, opts, mockLog); const expected = 'export default [x()];'; - t.is(result, expected); + t.is(result.code, expected); }); -test('compile from execution plan', async (t) => { +test.serial('compile from execution plan', async (t) => { const plan = { workflow: { steps: [ @@ -78,7 +77,7 @@ test('compile from execution plan', async (t) => { t.is((b as Job).expression, expected); }); -test('throw an AbortError if a job is uncompilable', async (t) => { +test.serial('throw an AbortError if a job is uncompilable', async (t) => { const job = 'a b'; const opts = {} as CompileOptions; @@ -93,7 +92,7 @@ test('throw an AbortError if a job is uncompilable', async (t) => { t.assert(logger._find('error', /critical error: aborting command/i)); }); -test('throw an AbortError if an xplan contains an uncompilable job', async (t) => { +test.serial('throw an AbortError if an xplan contains an uncompilable job', async (t) => { const plan: ExecutionPlan = { workflow: { steps: [{ id: 'a', expression: 'x b' }], @@ -113,35 +112,35 @@ test('throw an AbortError if an xplan contains an uncompilable job', async (t) = t.assert(logger._find('error', /critical error: aborting command/i)); }); -test('stripVersionSpecifier: remove version specifier from @openfn', (t) => { +test.serial('stripVersionSpecifier: remove version specifier from @openfn', (t) => { const specifier = '@openfn/language-common@3.0.0-rc2'; const transformed = stripVersionSpecifier(specifier); const expected = '@openfn/language-common'; t.assert(transformed == expected); }); -test('stripVersionSpecifier: remove version specifier from arbitrary package', (t) => { +test.serial('stripVersionSpecifier: remove version specifier from arbitrary package', (t) => { const specifier = 'ava@1.0.0'; const transformed = stripVersionSpecifier(specifier); const expected = 'ava'; t.assert(transformed == expected); }); -test('stripVersionSpecifier: remove version specifier from arbitrary namespaced package', (t) => { +test.serial('stripVersionSpecifier: remove version specifier from arbitrary namespaced package', (t) => { const specifier = '@ava/some-pkg@^1'; const transformed = stripVersionSpecifier(specifier); const expected = '@ava/some-pkg'; t.assert(transformed == expected); }); -test("stripVersionSpecifier: do nothing if there's no specifier", (t) => { +test.serial("stripVersionSpecifier: do nothing if there's no specifier", (t) => { const specifier = '@openfn/language-common'; const transformed = stripVersionSpecifier(specifier); const expected = '@openfn/language-common'; t.assert(transformed == expected); }); -test('loadTransformOptions: do nothing', async (t) => { +test.serial('loadTransformOptions: do nothing', async (t) => { const opts = {}; const result = loadTransformOptions(opts, mockLog); t.assert(JSON.stringify(result) === '{}'); @@ -158,22 +157,22 @@ test.serial( } ); -test('resolveSpecifierPath: return a relative path if passed', async (t) => { +test.serial('resolveSpecifierPath: return a relative path if passed', async (t) => { const path = await resolveSpecifierPath('pkg=./a', '/repo', mockLog); t.assert(path === './a'); }); -test('resolveSpecifierPath: return an absolute path if passed', async (t) => { +test.serial('resolveSpecifierPath: return an absolute path if passed', async (t) => { const path = await resolveSpecifierPath('pkg=/a', '/repo', mockLog); t.assert(path === '/a'); }); -test('resolveSpecifierPath: return a path if passed', async (t) => { +test.serial('resolveSpecifierPath: return a path if passed', async (t) => { const path = await resolveSpecifierPath('pkg=a/b/c', '/repo', mockLog); t.assert(path === 'a/b/c'); }); -test('resolveSpecifierPath: basically return anything after the =', async (t) => { +test.serial('resolveSpecifierPath: basically return anything after the =', async (t) => { const path = await resolveSpecifierPath('pkg=a', '/repo', mockLog); t.assert(path === 'a'); diff --git a/packages/compiler/CHANGELOG.md b/packages/compiler/CHANGELOG.md index 40893425e..0df488c83 100644 --- a/packages/compiler/CHANGELOG.md +++ b/packages/compiler/CHANGELOG.md @@ -1,5 +1,21 @@ # @openfn/compiler +## 1.0.0 + +### Major Changes + +- 4ddd4d6: Update main compile API to return a sourcemap + +### Minor Changes + +- 93e9844: Append positional information to top level operations + +### Patch Changes + +- Updated dependencies [6e87156] + - @openfn/logger@1.0.4 + - @openfn/lexicon@1.1.0 + ## 0.4.3 ### Patch Changes diff --git a/packages/compiler/package.json b/packages/compiler/package.json index 75eb3eaf0..7555dfd83 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -1,6 +1,6 @@ { "name": "@openfn/compiler", - "version": "0.4.3", + "version": "1.0.0", "description": "Compiler and language tooling for openfn jobs.", "author": "Open Function Group ", "license": "ISC", @@ -40,6 +40,7 @@ }, "dependencies": { "@openfn/describe-package": "workspace:*", + "@openfn/lexicon": "workspace:^", "@openfn/logger": "workspace:*", "acorn": "^8.8.0", "ast-types": "^0.14.2", diff --git a/packages/compiler/src/compile.ts b/packages/compiler/src/compile.ts index 0164592b1..b146273f4 100644 --- a/packages/compiler/src/compile.ts +++ b/packages/compiler/src/compile.ts @@ -1,9 +1,12 @@ import { print } from 'recast'; import createLogger, { Logger } from '@openfn/logger'; + import parse from './parse'; import transform, { TransformOptions } from './transform'; import { isPath, loadFile } from './util'; +import type { SourceMapWithOperations } from '@openfn/lexicon'; + const defaultLogger = createLogger(); // TODO want to migrate to this but it'll break everything... @@ -13,11 +16,18 @@ const defaultLogger = createLogger(); // } export type Options = TransformOptions & { + name?: string; logger?: Logger; logCompiledSource?: boolean; }; -export default function compile(pathOrSource: string, options: Options = {}) { +export default function compile( + pathOrSource: string, + options: Options = {} +): { + code: string; + map?: SourceMapWithOperations; +} { const logger = options.logger || defaultLogger; let source = pathOrSource; @@ -27,15 +37,22 @@ export default function compile(pathOrSource: string, options: Options = {}) { } else { //logger.debug('Starting compilation from string'); } - const ast = parse(source); + const name = options.name ?? 'src'; + const ast = parse(source, { name }); const transformedAst = transform(ast, undefined, options); - const compiledSource = print(transformedAst).code; + const { code, map } = print(transformedAst, { + sourceMapName: `${name}.map.js`, + }); + + // write the operations index to the source map + map.operations = (transformedAst.program as any).operations ?? []; + if (options.logCompiledSource) { logger.debug('Compiled source:'); - logger.debug(compiledSource); // TODO indent or something + logger.debug(code); } - return compiledSource; + return { code, map }; } diff --git a/packages/compiler/src/parse.ts b/packages/compiler/src/parse.ts index 16c723558..f9800fd89 100644 --- a/packages/compiler/src/parse.ts +++ b/packages/compiler/src/parse.ts @@ -10,12 +10,22 @@ */ import recast from 'recast'; import * as acorn from 'acorn'; +import { namedTypes } from 'ast-types'; -export default function parse(source: string) { - // This is copied from v1 but I am unsure the usecase +type Options = { + /** Name of the source job (no file extension). This triggers source map generation */ + name?: string; +}; + +export default function parse( + source: string, + options: Options = {} +): namedTypes.File { + // This is copied from v1 but I am unsure the use-case const escaped = source.replace(/\ $/, ''); const ast = recast.parse(escaped, { + sourceFileName: options.name && `${options.name}.js`, tolerant: true, range: true, parser: { diff --git a/packages/compiler/src/transform.ts b/packages/compiler/src/transform.ts index ec041fd49..913aafebb 100644 --- a/packages/compiler/src/transform.ts +++ b/packages/compiler/src/transform.ts @@ -45,7 +45,7 @@ export type TransformOptions = { const defaultLogger = createLogger(); export default function transform( - ast: namedTypes.Node, + ast: namedTypes.File, transformers?: Transformer[], options: TransformOptions = {} ) { diff --git a/packages/compiler/src/transforms/top-level-operations.ts b/packages/compiler/src/transforms/top-level-operations.ts index 50d7980a9..64a2f484e 100644 --- a/packages/compiler/src/transforms/top-level-operations.ts +++ b/packages/compiler/src/transforms/top-level-operations.ts @@ -8,6 +8,10 @@ import type { Transformer } from '../transform'; // Note that the validator should complain if it see anything other than export default [] // What is the relationship between the validator and the compiler? +export type ExtendedProgram = NodePath & { + operations: Array<{ line: number; name: string; order: number }>; +}; + export type TopLevelOpsOptions = { // Wrap operations in a `(state) => op` wrapper wrap: boolean; // TODO @@ -25,6 +29,8 @@ function visitor(path: NodePath) { (n.Identifier.check(path.node.callee) || n.MemberExpression.check(path.node.callee)) ) { + appendOperationMetadata(path, root as unknown as ExtendedProgram); + // Now Find the top level exports array const target = root.body.at(-1); if ( @@ -45,6 +51,26 @@ function visitor(path: NodePath) { // (should we only cancel traversal for this visitor?) } +// Add metadata to each operation for: +// - the order (do we know? We can guess from the state of the exports array +// - the name of the operation +// - the original line number (do we know?) +function appendOperationMetadata( + path: NodePath, + root: ExtendedProgram +) { + if (!root.operations) { + root.operations = []; + } + + const { operations } = root; + const order = operations.length + 1; + const name = path.value.callee.name; + const line = path.value.loc?.start.line ?? -1; + + operations.push({ line, order, name }); +} + export default { id: 'top-level-operations', types: ['CallExpression'], diff --git a/packages/compiler/test/compile.test.ts b/packages/compiler/test/compile.test.ts index abd4d220c..0372151a7 100644 --- a/packages/compiler/test/compile.test.ts +++ b/packages/compiler/test/compile.test.ts @@ -3,31 +3,65 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import compile from '../src/compile'; +// Not doing deep testing on this because recast does the heavy lifting +// This is just to ensure the map is actually generated +test('generate a source map', (t) => { + const source = 'fn();'; + const { map } = compile(source); + + t.truthy(map); + t.deepEqual(map!.sources, ['src.js']); + t.is(map!.file, 'src.map.js'); +}); + +test('generate a source map with operations', (t) => { + const source = 'fn();'; + const { map } = compile(source); + + t.truthy(map); + t.deepEqual(map!.operations, [ + { + name: 'fn', + order: 1, + line: 1, + }, + ]); +}); + +test('generate a named source map if a file name is passed', (t) => { + const source = 'fn();'; + const { map } = compile(source, { name: 'job' }); + + t.truthy(map); + t.deepEqual(map!.sources, ['job.js']); + t.is(map!.file, 'job.map.js'); +}); + test('ensure default exports is created', (t) => { const source = ''; const expected = 'export default [];'; - const result = compile(source); + const { code: result } = compile(source); t.is(result, expected); }); test('do not add default exports if exports exist', (t) => { const source = 'export const x = 10;'; const expected = 'export const x = 10;'; - const result = compile(source); + const { code: result } = compile(source); t.is(result, expected); }); test('compile a single operation', (t) => { const source = 'fn();'; const expected = 'export default [fn()];'; - const result = compile(source); + const { code: result } = compile(source); t.is(result, expected); }); test('compile a single namespaced operation', (t) => { const source = 'http.get();'; const expected = 'export default [http.get()];'; - const result = compile(source); + const { code: result } = compile(source); t.is(result, expected); }); @@ -35,21 +69,21 @@ test('compile a const assignment with single method call', (t) => { const source = 'const x = dateFns.parse()'; const expected = `const x = dateFns.parse() export default [];`; - const result = compile(source); + const { code: result } = compile(source); t.is(result, expected); }); test('compile a single operation without being fussy about semicolons', (t) => { const source = 'fn()'; const expected = 'export default [fn()];'; - const result = compile(source); + const { code: result } = compile(source); t.is(result, expected); }); test('compile multiple operations', (t) => { const source = 'fn();fn();fn();'; const expected = 'export default [fn(), fn(), fn()];'; - const result = compile(source); + const { code: result } = compile(source); t.is(result, expected); }); @@ -66,7 +100,7 @@ test('add imports', (t) => { }; const source = 'fn();'; const expected = `import { fn } from "@openfn/language-common";\nexport default [fn()];`; - const result = compile(source, options); + const { code: result } = compile(source, options); t.is(result, expected); }); @@ -84,7 +118,7 @@ test('do not add imports', (t) => { // This example already has the correct imports declared, so add-imports should do nothing const source = "import { fn } from '@openfn/language-common'; fn();"; const expected = `import { fn } from '@openfn/language-common';\nexport default [fn()];`; - const result = compile(source, options); + const { code: result } = compile(source, options); t.is(result, expected); }); @@ -101,7 +135,7 @@ test('dumbly add imports', (t) => { // This example already has the correct imports declared, so add-imports should do nothing const source = "import { jam } from '@openfn/language-common'; jam(state);"; const expected = `import { jam } from '@openfn/language-common';\nexport default [jam(state)];`; - const result = compile(source, options); + const { code: result } = compile(source, options); t.is(result, expected); }); @@ -119,7 +153,7 @@ test('add imports with export all', (t) => { }; const source = 'fn();'; const expected = `import { fn } from "@openfn/language-common";\nexport * from "@openfn/language-common";\nexport default [fn()];`; - const result = compile(source, options); + const { code: result } = compile(source, options); t.is(result, expected); }); @@ -134,28 +168,28 @@ test('twitter example', async (t) => { path.resolve('test/jobs/twitter.compiled.js'), 'utf8' ); - const result = compile(source); + const { code: result } = compile(source); t.deepEqual(result, expected); }); test('compile with optional chaining', (t) => { const source = 'fn(a.b?.c);'; const expected = 'export default [fn(a.b?.c)];'; - const result = compile(source); + const { code: result } = compile(source); t.is(result, expected); }); test('compile with nullish coalescence', (t) => { const source = 'fn(a ?? b);'; const expected = 'export default [fn(a ?? b)];'; - const result = compile(source); + const { code: result } = compile(source); t.is(result, expected); }); test('compile a lazy state ($) expression', (t) => { const source = 'get($.data.endpoint);'; const expected = 'export default [get(state => state.data.endpoint)];'; - const result = compile(source); + const { code: result } = compile(source); t.is(result, expected); }); @@ -175,7 +209,7 @@ test('compile a lazy state ($) expression with dumb imports', (t) => { export * from "@openfn/language-common"; export default [get(state => state.data.endpoint)];`; - const result = compile(source, options); + const { code: result } = compile(source, options); t.is(result, expected); }); @@ -190,7 +224,7 @@ export default [_defer( p => p.then((s => { console.log(s.data); return state;} )) )];`; - const result = compile(source); + const { code: result } = compile(source); t.is(result, expected); }); @@ -207,6 +241,6 @@ export default [each( _defer(post("/upsert", (state) => state.data), p => p.then((s) => s)) )];`; - const result = compile(source); + const { code: result } = compile(source); t.is(result, expected); }); diff --git a/packages/compiler/test/parse.test.ts b/packages/compiler/test/parse.test.ts index 78e440d48..2feda5616 100644 --- a/packages/compiler/test/parse.test.ts +++ b/packages/compiler/test/parse.test.ts @@ -6,6 +6,9 @@ import parse from '../src/parse'; import { loadAst } from './util'; +// Note that these tests do not include source mappings +// just because the ast changes and develops circular structures + test('parse a simple statement', (t) => { const source = 'const x = 10;'; diff --git a/packages/compiler/test/transforms/top-level-operations.test.ts b/packages/compiler/test/transforms/top-level-operations.test.ts index 720c59791..2361684ec 100644 --- a/packages/compiler/test/transforms/top-level-operations.test.ts +++ b/packages/compiler/test/transforms/top-level-operations.test.ts @@ -2,6 +2,7 @@ import test from 'ava'; import { builders as b, namedTypes as n } from 'ast-types'; import transform from '../../src/transform'; +import parse from '../../src/parse'; import visitors from '../../src/transforms/top-level-operations'; import { assertCodeEqual } from '../util'; @@ -139,7 +140,7 @@ test('moves a method call into the exports array', (t) => { t.is(call.callee.property.name, 'b'); }); -test('does not move a method call inside an asisignment', (t) => { +test('does not move a method call inside an assignment', (t) => { const ast = createProgramWithExports([ b.variableDeclaration('const', [ b.variableDeclarator( @@ -153,7 +154,6 @@ test('does not move a method call inside an asisignment', (t) => { ]); const { body } = transform(ast, [visitors]); - console.log(body); // should add the export t.is(body.length, 2); @@ -214,3 +214,20 @@ test('should only take the top of a nested operation call (and preserve its argu }); // TODO Does nothing if the export statement is wrong + +test('appends an operations map to simple operation', (t) => { + // We have to parse source here rather than building an AST so that we get positional information + const { program } = parse(`fn();`); + + transform(program, [visitors]); + + // @ts-ignore + const { operations } = program; + t.deepEqual(operations, [ + { + name: 'fn', + line: 1, + order: 1, + }, + ]); +}); diff --git a/packages/deploy/CHANGELOG.md b/packages/deploy/CHANGELOG.md index 0b4eb8074..e04009380 100644 --- a/packages/deploy/CHANGELOG.md +++ b/packages/deploy/CHANGELOG.md @@ -1,5 +1,12 @@ # @openfn/deploy +## 0.8.2 + +### Patch Changes + +- Updated dependencies [6e87156] + - @openfn/logger@1.0.4 + ## 0.8.1 ### Patch Changes diff --git a/packages/deploy/package.json b/packages/deploy/package.json index a18ee7744..560a14770 100644 --- a/packages/deploy/package.json +++ b/packages/deploy/package.json @@ -1,6 +1,6 @@ { "name": "@openfn/deploy", - "version": "0.8.1", + "version": "0.8.2", "description": "Deploy projects to Lightning instances", "type": "module", "exports": { diff --git a/packages/engine-multi/CHANGELOG.md b/packages/engine-multi/CHANGELOG.md index c3cd9fb86..91822fa8c 100644 --- a/packages/engine-multi/CHANGELOG.md +++ b/packages/engine-multi/CHANGELOG.md @@ -1,5 +1,19 @@ # engine-multi +## 1.4.7 + +### Patch Changes + +- Updated dependencies [4ddd4d6] +- Updated dependencies [93e9844] +- Updated dependencies [6e87156] +- Updated dependencies [aaa7e7b] +- Updated dependencies [4ddd4d6] + - @openfn/runtime@1.6.0 + - @openfn/compiler@1.0.0 + - @openfn/logger@1.0.4 + - @openfn/lexicon@1.1.0 + ## 1.4.6 ### Patch Changes diff --git a/packages/engine-multi/package.json b/packages/engine-multi/package.json index 4260aa88c..172b71e0e 100644 --- a/packages/engine-multi/package.json +++ b/packages/engine-multi/package.json @@ -1,6 +1,6 @@ { "name": "@openfn/engine-multi", - "version": "1.4.6", + "version": "1.4.7", "description": "Multi-process runtime engine", "main": "dist/index.js", "type": "module", diff --git a/packages/engine-multi/src/api/compile.ts b/packages/engine-multi/src/api/compile.ts index 2aa0ecfa1..1ee29101f 100644 --- a/packages/engine-multi/src/api/compile.ts +++ b/packages/engine-multi/src/api/compile.ts @@ -18,7 +18,9 @@ export default async (context: ExecutionContext) => { const job = step as Job; if (job.expression) { try { - job.expression = await compileJob(job, logger, repoDir); + const result = await compileJob(job, logger, repoDir); + job.expression = result.code; + job.sourceMap = result.map; } catch (e) { throw new CompileError(e, job.id!); } diff --git a/packages/lexicon/core.d.ts b/packages/lexicon/core.d.ts index dee4a491d..5ba75c6f7 100644 --- a/packages/lexicon/core.d.ts +++ b/packages/lexicon/core.d.ts @@ -1,4 +1,11 @@ import { SanitizePolicies } from '@openfn/logger'; +import type { RawSourceMap } from 'source-map'; + +export type SourceMap = RawSourceMap; + +export type SourceMapWithOperations = RawSourceMap & { + operations: [{ line: number; order: number; name: string }]; +}; /** * An execution plan is a portable definition of a Work Order, @@ -39,6 +46,8 @@ export interface Job extends Step { configuration?: object | string; state?: Omit | string; + sourceMap?: SourceMapWithOperations; + // Internal use only // Allow module paths and versions to be overridden in the linker // Maps to runtime.ModuleInfoMap diff --git a/packages/lexicon/package.json b/packages/lexicon/package.json index ec0c0660a..ab868ebbf 100644 --- a/packages/lexicon/package.json +++ b/packages/lexicon/package.json @@ -22,5 +22,8 @@ }, "devDependencies": { "@openfn/logger": "workspace:^" + }, + "dependencies": { + "source-map": "^0.7.4" } } diff --git a/packages/lightning-mock/CHANGELOG.md b/packages/lightning-mock/CHANGELOG.md index ab906fb17..6f2180916 100644 --- a/packages/lightning-mock/CHANGELOG.md +++ b/packages/lightning-mock/CHANGELOG.md @@ -1,5 +1,17 @@ # @openfn/lightning-mock +## 2.0.28 + +### Patch Changes + +- Updated dependencies [4ddd4d6] +- Updated dependencies [6e87156] +- Updated dependencies [aaa7e7b] + - @openfn/runtime@1.6.0 + - @openfn/logger@1.0.4 + - @openfn/engine-multi@1.4.7 + - @openfn/lexicon@1.1.0 + ## 2.0.27 ### Patch Changes diff --git a/packages/lightning-mock/package.json b/packages/lightning-mock/package.json index 6c1128a8a..198a5a955 100644 --- a/packages/lightning-mock/package.json +++ b/packages/lightning-mock/package.json @@ -1,6 +1,6 @@ { "name": "@openfn/lightning-mock", - "version": "2.0.27", + "version": "2.0.28", "private": true, "description": "A mock Lightning server", "main": "dist/index.js", diff --git a/packages/logger/CHANGELOG.md b/packages/logger/CHANGELOG.md index 4e43e6194..46c696b4d 100644 --- a/packages/logger/CHANGELOG.md +++ b/packages/logger/CHANGELOG.md @@ -1,5 +1,11 @@ # @openfn/logger +## 1.0.4 + +### Patch Changes + +- 6e87156: Fix an issue where print logs don't get picked up by the worker + ## 1.0.3 ### Patch Changes diff --git a/packages/logger/package.json b/packages/logger/package.json index 8b5198a15..f69f2ad14 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -1,6 +1,6 @@ { "name": "@openfn/logger", - "version": "1.0.3", + "version": "1.0.4", "description": "Cross-package logging utility", "module": "dist/index.js", "author": "Open Function Group ", diff --git a/packages/logger/src/logger.ts b/packages/logger/src/logger.ts index 05f2d3303..fa3fb12e6 100644 --- a/packages/logger/src/logger.ts +++ b/packages/logger/src/logger.ts @@ -241,7 +241,11 @@ export default function (name?: string, options: LogOptions = {}): Logger { const print = (...args: any[]) => { if (opts.level !== NONE) { if (opts.json) { - emitter.info({ message: args }); + emitter.info({ + message: args, + name, + time: hrtimestamp().toString(), + }); } else { emitter.info(...args); } diff --git a/packages/logger/src/mock.ts b/packages/logger/src/mock.ts index 14517788c..b2347c377 100644 --- a/packages/logger/src/mock.ts +++ b/packages/logger/src/mock.ts @@ -56,7 +56,8 @@ const mockLogger = ( mock.break = () => {}; // do nothing - // TODO should this use json? + // Note: the print mock emits to "print" for test purposes + // but the actual logger prints to info mock.print = (...out: any[]) => { if (opts.level !== 'none') { if (opts.json) { diff --git a/packages/logger/test/logger.test.ts b/packages/logger/test/logger.test.ts index 2ccf2f415..ad684d773 100644 --- a/packages/logger/test/logger.test.ts +++ b/packages/logger/test/logger.test.ts @@ -107,16 +107,15 @@ test('do log null after a string', (t) => { t.is(logger._history.length, 1); }); -test("log objects with null prototype", (t) => { +test('log objects with null prototype', (t) => { const logger = createLogger(undefined, { level: 'debug' }); - const obj = Object.create(null) + const obj = Object.create(null); logger.log(obj); t.is(logger._history.length, 1); }); - test('sanitize: remove object', (t) => { const logger = createLogger(undefined, { level: 'debug', diff --git a/packages/runtime/CHANGELOG.md b/packages/runtime/CHANGELOG.md index a8b9604b3..413e42082 100644 --- a/packages/runtime/CHANGELOG.md +++ b/packages/runtime/CHANGELOG.md @@ -1,5 +1,17 @@ # @openfn/runtime +## 1.6.0 + +### Minor Changes + +- 4ddd4d6: Update errors to include a source-mapped position and more dignostic information + +### Patch Changes + +- aaa7e7b: General improvements to how errors are reported. Includes a stack trace, removal of irrelevant or redundant information, and cleaner formatting +- Updated dependencies [6e87156] + - @openfn/logger@1.0.4 + ## 1.5.4 ### Patch Changes diff --git a/packages/runtime/package.json b/packages/runtime/package.json index f5d4e816d..b841936df 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@openfn/runtime", - "version": "1.5.4", + "version": "1.6.0", "description": "Job processing runtime.", "type": "module", "exports": { @@ -26,6 +26,7 @@ "author": "Open Function Group ", "license": "ISC", "devDependencies": { + "@openfn/compiler": "workspace:^", "@openfn/language-common": "2.0.0-rc3", "@openfn/lexicon": "workspace:^", "@types/mock-fs": "^4.13.1", @@ -33,6 +34,8 @@ "@types/semver": "^7.5.0", "ava": "5.3.1", "mock-fs": "^5.4.1", + "recast": "^0.21.5", + "ts-node": "^10.9.1", "tslib": "^2.4.0", "tsup": "^7.2.0", "typescript": "^5.1.6" @@ -45,6 +48,7 @@ "dependencies": { "@openfn/logger": "workspace:*", "fast-safe-stringify": "^2.1.1", - "semver": "^7.5.4" + "semver": "^7.5.4", + "source-map": "^0.7.4" } } diff --git a/packages/runtime/src/errors.ts b/packages/runtime/src/errors.ts index 8a19f1c10..1814788ef 100644 --- a/packages/runtime/src/errors.ts +++ b/packages/runtime/src/errors.ts @@ -1,10 +1,4 @@ -// TODO: what if we add a "fix" to each error? -// Maybe adminFix and userFix? -// This would be a human readable hint about what to do -// Or maybe summary/detail is a nicer approach -// message/explanation -// It would be nice for the detail to be in the error, not the code -// But that probably requires more detailed error types +import { ErrorPosition } from './types'; export function assertImportError(e: any) { if (e.name === 'ImportError') { @@ -34,32 +28,77 @@ export function assertSecurityKill(e: any) { } } +// Adaptor errors are caught and generated deep inside the runtime +// So they're easy to detect and we just re-throw them here export function assertAdaptorError(e: any) { + if (e.name === 'AdaptorError') { + throw e; + } +} + +// v8 only returns positional information as a string +// this function will pull the line/col information back out of it +export const extractPosition = (e: Error, vmOnly = false) => { if (e.stack) { - // parse the stack - const frames = e.stack.split('\n'); - frames.shift(); // remove the first line + const [_message, ...frames] = e.stack.split('\n'); + while (frames.length) { + const pos = extractPositionForFrame(frames.shift()!, vmOnly); + if (pos) { + return pos; + } + } + } +}; - const first = frames.shift(); +export const extractPositionForFrame = ( + frame: string, + vmOnly = false +): ErrorPosition | undefined => { + if (vmOnly && !frame.match(/vm:module\(0\)/)) { + return; + } + + // find the line:col at the end of the line + // structures here https://nodejs.org/api/errors.html#errorstack + if (frame.match(/\d+:\d+/)) { + const parts = frame.split(':'); + return { + column: parseInt(parts.pop()!.replace(')', '')), + line: parseInt(parts.pop()!), + }; + } +}; - // For now, we assume this is adaptor code if it has not come directly from the vm - // TODO: how reliable is this? Can we get a better heuristic? - if (first && !first.match(/at vm:module\(0\)/)) { - throw new AdaptorError(e); +export const extractStackTrace = (e: Error) => { + if (e.stack) { + const [message, ...frames] = e.stack.split('\n'); + + const vmFrames = []; + for (const frame of frames) { + // Include vm frames + // TODO: what if we rename the VM? + if (frame.includes('vm:module')) { + vmFrames.push(frame); + } + // Include adaptor stack frames (with local path removed) + if (frame.includes('@openfn/language-')) { + vmFrames.push(' ' + frame.split(/(@openfn\/language\-.*)/)[1]); + } } + + return [message, ...vmFrames].join('\n'); } -} +}; // Abstract error supertype export class RTError extends Error { source = 'runtime'; name: string = 'Error'; + pos?: ErrorPosition; + step?: string; constructor() { super(); - - // automatically limit the stacktrace (?) - Error.captureStackTrace(this, RTError.constructor); } } @@ -92,6 +131,9 @@ export class RuntimeError extends RTError { super(); this.subtype = error.constructor.name; this.message = `${this.subtype}: ${error.message}`; + + this.pos = extractPosition(error); + this.stack = extractStackTrace(error); } } @@ -107,6 +149,9 @@ export class RuntimeCrash extends RTError { super(); this.subtype = error.constructor.name; this.message = `${this.subtype}: ${error.message}`; + + this.pos = extractPosition(error); + this.stack = extractStackTrace(error); } } @@ -137,9 +182,34 @@ export class AdaptorError extends RTError { severity = 'fail'; message: string = ''; details: any; - constructor(error: any) { + + line?: number; + operationName?: string; + + constructor(error: any, line?: number, operationName?: string) { super(); - this.details = error; + if (!isNaN(line!)) { + this.line = line!; + this.stack = extractStackTrace(error); + } else { + // If no line/operation was passed, + // try and extract a position from the stack trace + this.pos = extractPosition(error, true); + this.stack = extractStackTrace(error); + } + + if (operationName) { + this.operationName = operationName; + } + + this.details = Object.assign( + { + type: error.type || error.name, + message: error.message, + }, + error + ); + if (typeof error === 'string') { this.message = error; } else if (error.message) { diff --git a/packages/runtime/src/execute/compile-plan.ts b/packages/runtime/src/execute/compile-plan.ts index 65a3b6e52..01a18a255 100644 --- a/packages/runtime/src/execute/compile-plan.ts +++ b/packages/runtime/src/execute/compile-plan.ts @@ -130,6 +130,7 @@ export default (plan: ExecutionPlan) => { 'state', 'configuration', 'name', + 'sourceMap', // TODO need unit tests against this ]); if (job.linker) { diff --git a/packages/runtime/src/execute/context.ts b/packages/runtime/src/execute/context.ts index afe45cc52..ab3b9f76b 100644 --- a/packages/runtime/src/execute/context.ts +++ b/packages/runtime/src/execute/context.ts @@ -48,7 +48,7 @@ export default ( return context; }; -// Special, highly restricted cotext for a plan condition +// Special, highly restricted context for a plan condition // Ie, a javascript expression export const conditionContext = () => { const context = vm.createContext( diff --git a/packages/runtime/src/execute/expression.ts b/packages/runtime/src/execute/expression.ts index 9b0da2498..e6fa3d825 100644 --- a/packages/runtime/src/execute/expression.ts +++ b/packages/runtime/src/execute/expression.ts @@ -1,5 +1,9 @@ import { printDuration, Logger } from '@openfn/logger'; -import type { Operation, State } from '@openfn/lexicon'; +import type { + Operation, + SourceMapWithOperations, + State, +} from '@openfn/lexicon'; import loadModule from '../modules/module-loader'; import { Options } from '../runtime'; @@ -15,6 +19,7 @@ import { assertRuntimeCrash, assertRuntimeError, assertSecurityKill, + AdaptorError, } from '../errors'; import type { JobModule, ExecutionContext } from '../types'; import { ModuleInfoMap } from '../modules/linker'; @@ -36,9 +41,10 @@ export default ( input: State, // allow custom linker options to be passed for this step // this lets us use multiple versions of the same adaptor in a workflow - moduleOverrides?: ModuleInfoMap -) => - new Promise(async (resolve, reject) => { + moduleOverrides?: ModuleInfoMap, + sourceMap?: SourceMapWithOperations +) => { + return new Promise(async (resolve, reject) => { let duration = Date.now(); const { logger, plan, opts = {} } = ctx; try { @@ -56,7 +62,14 @@ export default ( // Create the main reducer function const reducer = (execute || defaultExecute)( ...operations.map((op, idx) => - wrapOperation(op, logger, `${idx + 1}`, opts.immutableState) + wrapOperation( + op, + logger, + `${idx + 1}`, + idx, + opts.immutableState, + sourceMap + ) ) ); @@ -86,11 +99,11 @@ export default ( duration = Date.now() - duration; let finalError; try { + assertAdaptorError(e); assertImportError(e); assertRuntimeError(e); assertRuntimeCrash(e); assertSecurityKill(e); - assertAdaptorError(e); finalError = new JobError(e); } catch (e) { finalError = e; @@ -99,13 +112,15 @@ export default ( reject({ state: input, error: finalError } as ExecutionErrorWrapper); } }); +}; -// Wrap an operation with various useful stuff export const wrapOperation = ( fn: Operation, logger: Logger, name: string, - immutableState?: boolean + index: number, + immutableState?: boolean, + sourceMap?: SourceMapWithOperations ) => { return async (state: State) => { logger.debug(`Starting operation ${name}`); @@ -118,11 +133,53 @@ export const wrapOperation = ( } const newState = immutableState ? clone(state) : state; - let result = await fn(newState); + let result; + try { + result = await fn(newState); + } catch (e: any) { + if (e.stack) { + const containsVMFrame = e.stack.match(/at vm:module\(0\)/); + + // Is this an error from inside adaptor code? + const frames = e.stack.split('\n'); + frames.shift(); // remove the first line + + let firstFrame; + + // find the first error from a file or the VM + // (this cuts out low level language errors and stuff) + do { + const next = frames.shift(); + if (/(@openfn\/language-)|(vm:module)/.test(next)) { + firstFrame = next; + break; + } + } while (frames.length); + + // If that error did NOT come from the VM stack, it's an adaptor error + // This is a little sketchy for nested operations + if (firstFrame && !firstFrame.match(/at vm:module\(0\)/)) { + // If there is no vm stuff in the stack, attribute + // the error position to the correct operation in the sourcemap + let line, operationName; + if (!containsVMFrame && sourceMap?.operations) { + const position = sourceMap?.operations[index]; + line = position?.line; + operationName = position?.name; + } + + const error = new AdaptorError(e, line, operationName); + throw error; + } + } + + // Just re-throw the error to be handled elsewhere + throw e; + } if (!result) { logger.debug(`Warning: operation ${name} did not return state`); - result = createNullState(); + result = createNullState() as unknown as State; } // TODO should we warn if an operation does not return state? diff --git a/packages/runtime/src/execute/plan.ts b/packages/runtime/src/execute/plan.ts index 839c94cdc..17ca33ec2 100644 --- a/packages/runtime/src/execute/plan.ts +++ b/packages/runtime/src/execute/plan.ts @@ -8,7 +8,7 @@ import type { Options } from '../runtime'; import validatePlan from '../util/validate-plan'; import createErrorReporter from '../util/log-error'; import { NOTIFY_STATE_LOAD } from '../events'; -import { CompiledExecutionPlan } from '../types'; +import { CompiledExecutionPlan, ExecutionContext } from '../types'; const executePlan = async ( plan: ExecutionPlan, @@ -30,7 +30,7 @@ const executePlan = async ( const { workflow, options } = compiledPlan; - const ctx = { + const ctx: ExecutionContext = { plan: compiledPlan, opts, logger, @@ -44,7 +44,7 @@ const executePlan = async ( if (typeof input === 'string') { const id = input; const startTime = Date.now(); - logger.debug(`fetching intial state ${id}`); + logger.debug(`fetching initial state ${id}`); input = await opts.callbacks?.resolveState?.(id); const duration = Date.now() - startTime; diff --git a/packages/runtime/src/execute/step.ts b/packages/runtime/src/execute/step.ts index 3c4f5f8c9..52b1d5425 100644 --- a/packages/runtime/src/execute/step.ts +++ b/packages/runtime/src/execute/step.ts @@ -1,5 +1,3 @@ -// TODO hmm. I have a horrible feeling that the callbacks should go here -// at least the resolvesrs import type { Job, State, StepId } from '@openfn/lexicon'; import type { Logger } from '@openfn/logger'; @@ -16,6 +14,7 @@ import { NOTIFY_JOB_START, } from '../events'; import { isNullState } from '../util/null-state'; +import sourcemapErrors from '../util/sourcemap-errors'; const loadCredentials = async ( job: Job, @@ -157,16 +156,20 @@ const executeStep = async ( const timerId = `step-${jobId}`; logger.timer(timerId); - // TODO can we include the adaptor version here? - // How would we get it? + // TODO can/should we include the adaptor version here? logger.info(`Starting step ${jobName}`); const startTime = Date.now(); try { // TODO include the upstream job? notify(NOTIFY_JOB_START, { jobId }); - - result = await executeExpression(ctx, job.expression, state, step.linker); + result = await executeExpression( + ctx, + job.expression, + state, + step.linker, + job.sourceMap + ); } catch (e: any) { didError = true; if (e.hasOwnProperty('error') && e.hasOwnProperty('state')) { @@ -177,13 +180,13 @@ const executeStep = async ( logger.error(`${jobName} aborted with error (${duration})`); state = prepareFinalState(state, logger, ctx.opts.statePropsToRemove); - // Whatever the final state was, save that as the intial state to the next thing + // Whatever the final state was, save that as the initial state to the next thing result = state; + await sourcemapErrors(job, error); report(state, jobId, error); next = calculateNext(step, result, logger); - notify(NOTIFY_JOB_ERROR, { duration: Date.now() - startTime, error, @@ -238,7 +241,6 @@ const executeStep = async ( // calculate next for trigger nodes next = calculateNext(step, result, logger); } - if (next.length && !didError && !result) { logger.warn( `WARNING: step ${stepId} did not return a state object. This may cause downstream jobs to fail.` diff --git a/packages/runtime/src/runtime.ts b/packages/runtime/src/runtime.ts index b78d4829c..8b804a039 100644 --- a/packages/runtime/src/runtime.ts +++ b/packages/runtime/src/runtime.ts @@ -1,5 +1,9 @@ import { createMockLogger, Logger } from '@openfn/logger'; -import type { ExecutionPlan, State } from '@openfn/lexicon'; +import type { + ExecutionPlan, + State, + SourceMapWithOperations, +} from '@openfn/lexicon'; import type { ExecutionCallbacks } from './types'; import type { LinkerOptions } from './modules/linker'; import executePlan from './execute/plan'; @@ -8,26 +12,23 @@ import { defaultState, parseRegex, clone } from './util/index'; export type Options = { logger?: Logger; jobLogger?: Logger; - // Treat state as immutable (likely to break in legacy jobs) immutableState?: boolean; - // TODO currently unused // Ensure that all incoming jobs are sandboxed / loaded as text // In practice this means throwing if someone tries to pass live js forceSandbox?: boolean; - linker?: LinkerOptions; - callbacks?: ExecutionCallbacks; - // inject globals into the environment // TODO leaving this here for now, but maybe its actually on the xplan? globals?: any; - statePropsToRemove?: string[]; - defaultRunTimeoutMs?: number; + + // SourceMap is only passed directly if the expression is a string + // Usually a sourcemap is passed on a step + sourceMap?: SourceMapWithOperations; }; type RawOptions = Omit & { @@ -39,12 +40,17 @@ type RawOptions = Omit & { // Log nothing by default const defaultLogger = createMockLogger(); -const loadPlanFromString = (expression: string, logger: Logger) => { +const loadPlanFromString = ( + expression: string, + logger: Logger, + sourceMap?: SourceMapWithOperations +) => { const plan: ExecutionPlan = { workflow: { steps: [ { expression, + sourceMap, }, ], }, @@ -65,7 +71,7 @@ const run = ( const logger = opts.logger || defaultLogger; if (typeof xplan === 'string') { - xplan = loadPlanFromString(xplan, logger); + xplan = loadPlanFromString(xplan, logger, opts.sourceMap); } if (!xplan.options) { diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index c2756184e..f8f45c832 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -13,6 +13,12 @@ import { } from './events'; import { ModuleInfoMap } from './modules/linker'; +export type ErrorPosition = { + line: number; + column: number; + src?: string; // the source line for this error +}; + export type CompiledEdge = | boolean | { diff --git a/packages/runtime/src/util/log-error.ts b/packages/runtime/src/util/log-error.ts index fab712e47..112e1d5eb 100644 --- a/packages/runtime/src/util/log-error.ts +++ b/packages/runtime/src/util/log-error.ts @@ -24,11 +24,8 @@ const serialize = (error: any) => { return error; }; -// TODO this is really over complicated now -// Because we're taking closer control of errors -// we should be able to report more simply const createErrorReporter = (logger: Logger): ErrorReporter => { - return (state, stepId, error) => { + return (state, stepId, error: any) => { // TODO I don't think the report is useful anymore // we'll strip it all out soon // see https://github.com/OpenFn/kit/issues/726 @@ -45,21 +42,68 @@ const createErrorReporter = (logger: Logger): ErrorReporter => { report.stack = error.stack as string; } + logger.break(); if (error.severity === 'crash') { logger.error('CRITICAL ERROR! Aborting execution'); } - const serializedError = serialize(error); - logger.error(serializedError); + if (error.pos) { + if (error.stack) { + // If there's a stack trace, log it (it'll include position, message and type) + logger.error(error.stack); + logger.break(); + } else { + // If there's no stack trace, log the message and position + logger.error(error.message, `(${error.pos.line}:${error.pos.column})`); + } - if (error.severity === 'fail') { - logger.error(`Check state.errors.${stepId} for details.`); + if (error.pos.src) { + // Print the error line of code and a marker to the position + const { src } = error.pos; + const pointer = new Array(src.length).fill(' '); + pointer[error.pos.column - 1] = '^'; + + const prefix = `${error.pos.line}: `; + + logger.error('Error occurred at:', error.step ?? ''); + logger.error(`${prefix}${src}`); + logger.error(`${prefix.replace(/./g, ' ')}${pointer.join('')}`); + logger.error(); + } + } else if (error.line && error.operationName) { + // handle adaptor errors where we don't have a position that corresponds nicely to the sourcemapped code + logger.error( + `Error reported by "${error.operationName}()" operation line ${error.line}:` + ); - if (!state.errors) { - state.errors = {}; + // Log the stack or the message, depending on what we have + if (error.stack) { + logger.error(error.stack); + } else { + logger.error(error.message); } + } else if (error.line && error.operationName) { + // handle adaptor errors where we don't have a position that corresponds nicely to the sourcemapped code + logger.error( + `Error reported by "${error.operationName}()" operation line ${error.line}:` + ); + logger.error(error.message); + } else { + logger.error(error.message); + } + + if (error.details) { + logger.error('Additional error details:'); + logger.print(error.details); + logger.break(); + } + + if (error.severity === 'fail') { + // Write a safely serialzied error object to state + state.errors ??= {}; + state.errors[stepId] = serialize(error); - state.errors[stepId] = serializedError; + logger.error(`Check state.errors.${stepId} for details`); } return report as ErrorReport; diff --git a/packages/runtime/src/util/pick.ts b/packages/runtime/src/util/pick.ts new file mode 100644 index 000000000..7fb8c6ac4 --- /dev/null +++ b/packages/runtime/src/util/pick.ts @@ -0,0 +1,11 @@ +export default ( + obj: T, + ...keys: U[] +): Pick => + keys.reduce((acc, key) => { + if (key in obj) { + // @ts-ignore + acc[key] = obj[key]; + } + return acc; + }, {} as T); diff --git a/packages/runtime/src/util/sourcemap-errors.ts b/packages/runtime/src/util/sourcemap-errors.ts new file mode 100644 index 000000000..331bc5140 --- /dev/null +++ b/packages/runtime/src/util/sourcemap-errors.ts @@ -0,0 +1,71 @@ +import { SourceMapConsumer } from 'source-map'; +import { extractPositionForFrame, RTError } from '../errors'; +import pick from './pick'; +import type { Job } from '@openfn/lexicon'; + +// This function takes an error and a job and updates the error with sourcemapped metadata +export default async (job: Job, error: RTError) => { + if (job.sourceMap) { + const smc = await new SourceMapConsumer(job.sourceMap); + + if (error.pos && error.pos.line && error.pos.column) { + error.pos = pick( + smc.originalPositionFor({ + line: error.pos.line, + column: error.pos.column, + }) as any, + 'line', + 'column' + ); + + error.step = job.name || job.id; + } + + if (error.stack) { + error.stack = await mapStackTrace(smc, error.stack); + } + + if (error.pos && !isNaN(error.pos.line!)) { + // TODO how to handle file name properly here? + const src = smc.sourceContentFor('src.js')!.split('\n'); + const line = src[error.pos.line! - 1]; + error.pos.src = line; + } + } +}; + +export const mapStackTrace = async ( + smc: SourceMapConsumer, + stacktrace: string +) => { + const lines = stacktrace.split('\n'); + + const newStack = [lines.shift()]; // first line is the error message + for (const line of lines) { + try { + const pos = extractPositionForFrame(line); + if (pos) { + // TODO not sure about these typings tbh + const originalPos = smc.originalPositionFor({ + line: pos.line!, + column: pos.column!, + }); + if (originalPos.line && originalPos.column) { + newStack.push( + line.replace( + `${pos.line}:${pos.column}`, + `${originalPos.line}:${originalPos.column}` + ) + ); + } else { + newStack.push(line); + } + } + } catch (e) { + // do nothing + newStack.push(line); + } + } + + return newStack.join('\n'); +}; diff --git a/packages/runtime/test/__modules__/test/index.js b/packages/runtime/test/__modules__/@openfn/language-test/index.js similarity index 68% rename from packages/runtime/test/__modules__/test/index.js rename to packages/runtime/test/__modules__/@openfn/language-test/index.js index bf093b2b3..fc0d520a1 100644 --- a/packages/runtime/test/__modules__/test/index.js +++ b/packages/runtime/test/__modules__/@openfn/language-test/index.js @@ -3,9 +3,11 @@ export const x = 'test'; export default x; export const err = () => { - const e = new Error('adaptor err'); - e.code = 1234; - throw e; + return async (state) => { + const e = new Error('adaptor err'); + e.code = 1234; + throw e; + }; }; export const err2 = () => { @@ -22,3 +24,5 @@ export function call(fn) { } }; } + +export const fn = (f) => (state) => f(state); diff --git a/packages/runtime/test/__modules__/test/package.json b/packages/runtime/test/__modules__/@openfn/language-test/package.json similarity index 100% rename from packages/runtime/test/__modules__/test/package.json rename to packages/runtime/test/__modules__/@openfn/language-test/package.json diff --git a/packages/runtime/test/errors.test.ts b/packages/runtime/test/errors.test.ts index 4b06f8875..38c6cca17 100644 --- a/packages/runtime/test/errors.test.ts +++ b/packages/runtime/test/errors.test.ts @@ -1,8 +1,10 @@ import test from 'ava'; import path from 'node:path'; import type { WorkflowOptions } from '@openfn/lexicon'; +import compile from '@openfn/compiler'; import run from '../src/runtime'; +import { extractPosition, extractStackTrace } from '../src/errors'; const createPlan = (expression: string, options: WorkflowOptions = {}) => ({ workflow: { @@ -15,6 +17,86 @@ const createPlan = (expression: string, options: WorkflowOptions = {}) => ({ options, }); +test('extractPosition: basic test', (t) => { + const fakeError = { + stack: `Error: some error + at assertRuntimeCrash (/repo/openfn/kit/packages/runtime/src/errors.ts:25:15)`, + }; + + const pos = extractPosition(fakeError as Error); + + t.deepEqual(pos, { + line: 25, + column: 15, + }); +}); + +test("extractPosition: find errors which aren't on line 1", (t) => { + const fakeError = { + stack: `Error: some error + at Number.toFixed () + at assertRuntimeCrash (/repo/openfn/kit/packages/runtime/src/errors.ts:25:15)`, + }; + + const pos = extractPosition(fakeError as Error); + + t.deepEqual(pos, { + line: 25, + column: 15, + }); +}); + +test('extractPosition: only include vm frames', (t) => { + const fakeError = { + stack: `Error: some error + at Number.toFixed () + at assertRuntimeCrash (/repo/openfn/kit/packages/runtime/src/errors.ts:25:15), + at vm:module(0):2:17`, + }; + + const pos = extractPosition(fakeError as Error, true); + + t.deepEqual(pos, { + line: 2, + column: 17, + }); +}); + +test("extractPosition: return undefined if there's no position", (t) => { + const fakeError = { + stack: `Error: some error + at Number.toFixed () + at assertRuntimeCrash (/repo/openfn/kit/packages/runtime/src/errors.ts)`, + }; + + const pos = extractPosition(fakeError as Error); + + t.falsy(pos); +}); + +test('extractStackTrace: basic test', (t) => { + const fakeError = { + stack: `ReferenceError: z is not defined + at vm:module(0):2:27 + at fn (vm:module(0):1:25) + at vm:module(0):2:17 + at SourceTextModule.evaluate (node:internal/vm/module:227:23) + at default (file:///repo/openfn/kit/packages/runtime/src/modules/module-loader.ts:29:18) + at process.processTicksAndRejections (node:internal/process/task_queues:105:5) + at async prepareJob (file:///repo/openfn/kit/packages/runtime/src/execute/expression.ts:136:25) + at async file:///repo/openfn/kit/packages/runtime/src/execute/expression.ts:21:45`, + }; + + const stack = extractStackTrace(fakeError as Error); + t.is( + stack, + `ReferenceError: z is not defined + at vm:module(0):2:27 + at fn (vm:module(0):1:25) + at vm:module(0):2:17` + ); +}); + test('crash on timeout', async (t) => { const expression = 'export default [(s) => new Promise((resolve) => {})]'; @@ -57,11 +139,65 @@ test('crash on runtime error with ReferenceError', async (t) => { error = e; } t.log(error); + t.log(error.stack); - // t.true(error instanceof RuntimeError); t.is(error.severity, 'crash'); t.is(error.subtype, 'ReferenceError'); t.is(error.message, 'ReferenceError: x is not defined'); + + // Ensure an unmapped error position + t.deepEqual(error.pos, { + line: 1, + column: 24, + }); + + // Ensure the stack trace only includes VM frames + t.is( + error.stack, + `ReferenceError: x is not defined + at vm:module(0):1:24` + ); +}); + +test('maps positions in a compiled ReferenceError', async (t) => { + const expression = `function fn(f) { return f() } +fn((s) => z)`; + + // Assert that in the original code, the undeclared variable is at position 11 + const originalZPosition = expression.split('\n')[1].indexOf('z'); + t.is(originalZPosition, 10); + + // compile the code so we get a source map + const { code, map } = compile(expression, { name: 'src' }); + t.log(code); + let error: any; + try { + await run(code, {}, { sourceMap: map }); + } catch (e) { + error = e; + } + + const newZPosition = code.split('\n')[1].indexOf('z'); + t.is(newZPosition, 26); + + // validate that this is the error we're expecting + t.is(error.subtype, 'ReferenceError'); + + // ensure a position is written to the error + t.deepEqual(error.pos, { + line: 2, + column: 11, + src: 'fn((s) => z)', + }); + + // Positions must be mapped in the stacktrace too + t.is( + error.stack, + `ReferenceError: z is not defined + at vm:module(0):2:11 + at fn (vm:module(0):1:25) + at vm:module(0):2:1` + ); }); test('crash on eval with SecurityError', async (t) => { @@ -166,10 +302,13 @@ test('fail on runtime TypeError', async (t) => { subtype: 'TypeError', severity: 'fail', source: 'runtime', + pos: { + column: 28, + line: 1, + }, }); }); -// TODO not totally convinced on this one actually test('fail on runtime error with RangeError', async (t) => { const expression = 'export default [(s) => Number.parseFloat("1").toFixed(-1)]'; @@ -185,6 +324,10 @@ test('fail on runtime error with RangeError', async (t) => { subtype: 'RangeError', severity: 'fail', source: 'runtime', + pos: { + column: 47, + line: 1, + }, }); }); @@ -220,20 +363,89 @@ test('fail on user error with throw "abort"', async (t) => { }); }); -test('fail on adaptor error (with throw new Error())', async (t) => { +test('fail on adaptor error and map to the top operation', async (t) => { const expression = ` - import { err } from 'x'; - export default [(s) => err()]; - `; + + err();`; + + // Compile the code so that we get a source map + const { code, map } = compile(expression, { + name: 'src', + 'add-imports': { + adaptors: [ + { + name: 'x', + exportAll: true, + }, + ], + }, + }); + const result: any = await run( - expression, + code, + {}, + { + linker: { + modules: { + x: { path: path.resolve('test/__modules__/@openfn/language-test') }, + }, + }, + sourceMap: map, + } + ); + + const error = result.errors['job-1']; + + t.deepEqual(error, { + details: { + code: 1234, + message: 'adaptor err', + type: 'Error', + }, + message: 'adaptor err', + name: 'AdaptorError', + source: 'runtime', + severity: 'fail', + line: 3, + operationName: 'err', + }); +}); + +test('fail on nested adaptor error and map to a position in the vm', async (t) => { + // have to use try/catch or we'll get an unhandled rejection error + // TODO does this need wider testing? + const expression = ` + fn(async (state) => { + try { + await err()(state); + } catch(e) { + throw e; + } + })`; + + // Compile the code so that we get a source map + const { code, map } = compile(expression, { + name: 'src', + 'add-imports': { + adaptors: [ + { + name: 'x', + exportAll: true, + }, + ], + }, + }); + + const result: any = await run( + code, {}, { linker: { modules: { - x: { path: path.resolve('test/__modules__/test') }, + x: { path: path.resolve('test/__modules__/@openfn/language-test') }, }, }, + sourceMap: map, } ); @@ -243,11 +455,19 @@ test('fail on adaptor error (with throw new Error())', async (t) => { t.deepEqual(error, { details: { code: 1234, + message: 'adaptor err', + type: 'Error', }, message: 'adaptor err', name: 'AdaptorError', source: 'runtime', severity: 'fail', + step: 'job-1', + pos: { + column: 20, + line: 4, + src: ' await err()(state);', + }, }); }); @@ -265,7 +485,7 @@ test('adaptor error with no stack trace will be a user error', async (t) => { { linker: { modules: { - x: { path: path.resolve('test/__modules__/test') }, + x: { path: path.resolve('test/__modules__/@openfn/language-test') }, }, }, } diff --git a/packages/runtime/test/execute/plan.test.ts b/packages/runtime/test/execute/plan.test.ts index 083f6bdc1..d8be4a1d3 100644 --- a/packages/runtime/test/execute/plan.test.ts +++ b/packages/runtime/test/execute/plan.test.ts @@ -917,22 +917,17 @@ test('log appropriately on error', async (t) => { ]); const logger = createMockLogger(undefined, { level: 'debug' }); - await executePlan(plan, {}, {}, logger); + const err = logger._find('error', /aborted with error/i); t.truthy(err); - console.log('msg:', err?.message); + t.log('msg:', err?.message); t.regex(err!.message as string, /job1 aborted with error \(\d+ms\)/i); t.truthy(logger._find('error', /Check state.errors.job1 for details/i)); - const [_level, _icon, errObj]: any = logger._history.at(-2); - t.deepEqual(JSON.parse(errObj), { - source: 'runtime', - name: 'JobError', - severity: 'fail', - message: 'e', - }); + const [_level, _icon, errMessage]: any = logger._history.at(-2); + t.deepEqual(errMessage, 'e'); }); test('steps do not share a local scope', async (t) => { @@ -1169,6 +1164,10 @@ test('steps cannot pass functions to each other', async (t) => { name: 'RuntimeError', subtype: 'TypeError', message: 'TypeError: s.data.x is not a function', + pos: { + column: 29, + line: 2, + }, }); }); diff --git a/packages/runtime/test/execute/wrap-operation.test.ts b/packages/runtime/test/execute/wrap-operation.test.ts index 8c9649a38..05bedb405 100644 --- a/packages/runtime/test/execute/wrap-operation.test.ts +++ b/packages/runtime/test/execute/wrap-operation.test.ts @@ -3,14 +3,15 @@ import { createMockLogger } from '@openfn/logger'; import execute from '../../src/util/execute'; import { wrapOperation } from '../../src/execute/expression'; -import { Operation } from '../../src/types'; + +import type { Operation } from '@openfn/lexicon'; const logger = createMockLogger(); // This function mimics the reducer created in src/execute/expression.ts const reducer = async (operations: Operation[], state: any) => { const mapped = operations.map((op, idx) => - wrapOperation(op, logger, `${idx + 1}`) + wrapOperation(op, logger, `${idx + 1}`, idx) ); return execute(...mapped)(state); @@ -50,7 +51,7 @@ test('call several operations', async (t) => { t.deepEqual(result, 3); }); -test('catch a thrown error', async (t) => { +test('rethrow a thrown error', async (t) => { const op = () => { throw new Error('err'); }; @@ -60,7 +61,7 @@ test('catch a thrown error', async (t) => { }); }); -test('catch a thrown error async', async (t) => { +test('rethrow a thrown error async', async (t) => { const op = async () => { throw new Error('err'); }; @@ -70,8 +71,8 @@ test('catch a thrown error async', async (t) => { }); }); -test('catch a thrown nested reference error', async (t) => { - const op = () => { +test('rethrow a thrown nested reference error', async (t) => { + const op = async () => { const doTheThing = () => { // @ts-ignore unknown.doTheThing(); @@ -86,7 +87,7 @@ test('catch a thrown nested reference error', async (t) => { }); }); -test('catch a thrown nested reference error in a promise', async (t) => { +test('rethrow a thrown nested reference error in a promise', async (t) => { const op = () => new Promise(() => { const doTheThing = () => { @@ -103,7 +104,7 @@ test('catch a thrown nested reference error in a promise', async (t) => { }); }); -test('catch an illegal function call', async (t) => { +test('rethrow an illegal function call', async (t) => { const op = async (s: any) => { s(); }; @@ -114,7 +115,7 @@ test('catch an illegal function call', async (t) => { }); }); -test('catch an indirect type error', async (t) => { +test('rethrow an indirect type error', async (t) => { const op = (x: any) => { return async (_s: any) => x(); }; @@ -124,3 +125,20 @@ test('catch an indirect type error', async (t) => { message: 'x is not a function', }); }); + +test('rethrow a job error', async (t) => { + const op = (x: any) => { + return async (_s: any) => { + // Create something that looks like an error thrown from VM code + const e = new TypeError('x is not a function'); + e.stack = `TypeError: x is not a function + at vm:module(0)`; + throw e; + }; + }; + + await t.throwsAsync(() => reducer([op('jam')], {}), { + name: 'TypeError', + message: 'x is not a function', + }); +}); diff --git a/packages/runtime/test/modules/linker.test.ts b/packages/runtime/test/modules/linker.test.ts index 813a38e56..5b0478e7a 100644 --- a/packages/runtime/test/modules/linker.test.ts +++ b/packages/runtime/test/modules/linker.test.ts @@ -97,7 +97,7 @@ test('loads a module from a path', async (t) => { const m = await linker('ultimate-answer', context, { modules: { ['ultimate-answer']: { - path: path.resolve('test/__modules__/test'), + path: path.resolve('test/__modules__/@openfn/language-test'), version: '0.0.1', }, }, diff --git a/packages/runtime/test/runtime.test.ts b/packages/runtime/test/runtime.test.ts index 719aa9db4..08f5ef29e 100644 --- a/packages/runtime/test/runtime.test.ts +++ b/packages/runtime/test/runtime.test.ts @@ -643,7 +643,7 @@ test('data can be an array (workflow)', async (t) => { t.deepEqual(result.data, [1, 2, 3]); }); -test('import from a module', async (t) => { +test.only('import from a module', async (t) => { const expression = ` import { x } from 'x'; export default [(s) => ({ data: x })]; @@ -655,7 +655,7 @@ test('import from a module', async (t) => { { linker: { modules: { - x: { path: path.resolve('test/__modules__/test') }, + x: { path: path.resolve('test/__modules__/@openfn/language-test') }, }, }, } @@ -720,7 +720,7 @@ test('run from an adaptor', async (t) => { { linker: { modules: { - x: { path: path.resolve('test/__modules__/test') }, + x: { path: path.resolve('test/__modules__/@openfn/language-test') }, }, }, } @@ -842,7 +842,7 @@ test('run from an adaptor with error', async (t) => { { linker: { modules: { - x: { path: path.resolve('test/__modules__/test') }, + x: { path: path.resolve('test/__modules__/@openfn/language-test') }, }, }, } diff --git a/packages/runtime/test/util/pick.test.ts b/packages/runtime/test/util/pick.test.ts new file mode 100644 index 000000000..31d124898 --- /dev/null +++ b/packages/runtime/test/util/pick.test.ts @@ -0,0 +1,63 @@ +import test from 'ava'; +import pick from '../../src/util/pick'; + +test('should pick a key', (t) => { + const obj = { + a: 1, + b: 2, + c: 3, + }; + const result = pick(obj, 'b'); + t.deepEqual(result, { b: 2 }); +}); + +test('should pick multiple keys', (t) => { + const obj = { + a: 1, + b: 2, + c: 3, + d: 4, + }; + const result = pick(obj, 'b', 'c'); + t.deepEqual(result, { b: 2, c: 3 }); +}); + +test('should pick nothing if no keys passed', (t) => { + const obj = { + a: 1, + b: 2, + c: 3, + d: 4, + }; + const result = pick(obj); + t.deepEqual(result, {}); +}); + +test('should not mutate', (t) => { + const obj = { + a: 1, + b: 2, + c: 3, + d: 4, + }; + const result = pick(obj, 'd'); + t.deepEqual(result, { d: 4 }); + t.deepEqual(obj, { + a: 1, + b: 2, + c: 3, + d: 4, + }); +}); + +test("shouldn't pick empty keys", (t) => { + const obj = { + a: 1, + b: 2, + c: 3, + d: 4, + }; + // @ts-ignore + const result = pick(obj, 'a', 'z'); + t.deepEqual(result, { a: 1 }); +}); diff --git a/packages/runtime/test/util/sourcemap-errors.test.ts b/packages/runtime/test/util/sourcemap-errors.test.ts new file mode 100644 index 000000000..f12a71a7e --- /dev/null +++ b/packages/runtime/test/util/sourcemap-errors.test.ts @@ -0,0 +1,147 @@ +import test from 'ava'; +import recast from 'recast'; +import mapErrors from '../../src/util/sourcemap-errors'; +import { RTError } from '../../src'; + +const b = recast.types.builders; + +// compile an expression into a function +const compile = (src: string) => { + const ast = recast.parse(src, { + sourceFileName: 'src.js', + }); + + // take the expression and wrap it in a function declaration + const [{ expression }] = ast.program.body; + const fn = b.functionDeclaration( + b.identifier('fn'), + [], + b.blockStatement([b.returnStatement(expression)]) + ); + + ast.program.body.push(fn); + + return recast.print(fn, { + sourceMapName: 'src.map.js', + }); +}; + +test('should write the step name to the error', async (t) => { + const { code, map } = compile('x + 1'); + + // create a fake job + const job = { + expression: code, + sourceMap: map, + name: 'yyy', + }; + + // create a fake Error coming from the compiled 'x' + const error = new RTError(); + error.pos = { + line: 2, + column: code.split('\n')[1].indexOf('x'), + }; + + await mapErrors(job, error); + // Error should now be mapped to the old position + t.is(error.step, 'yyy'); +}); + +test('should re-write the position and source line of an error', async (t) => { + // Compile this simple expression into a function + const { code, map } = compile('x + 1'); + const lines = code.split('\n'); + + // create a fake job + const job = { + expression: code, + sourceMap: map, + }; + + // create a fake Error coming from the compiled 'x' + const error = new RTError(); + (error as any).pos = { + line: 2, + column: lines[1].indexOf('x'), + }; + t.log(error); + + await mapErrors(job, error); + + // Error should now be mapped to the old position + t.deepEqual(error.pos, { + line: 1, + column: 0, + src: 'x + 1', + }); +}); + +test('should map positions in a stack trace', async (t) => { + const src = `fn((x) => z)`; + + // compile the code with a source map + const { map, code } = compile(src); + + // create a fake job + const job = { + expression: code, + sourceMap: map, + }; + + // Find the column positions of x and z + // We'll make arbitrary mappings for those positions; + const lines = code.split('\n'); + const zPos = lines[1].indexOf('z'); + const xPos = lines[1].indexOf('x'); + + const error = new RTError(); + (error as any).pos = { + line: 2, + column: lines[1].indexOf('x'), + }; + // here's a fake but vaguely plausible stack trace + error.stack = `ReferenceError: z is not defined + at vm:module(0):2:${zPos} + at fn (vm:module(0):2:${xPos})`; + + await mapErrors(job, error); + t.log(error.stack); + + // Here's what we expect in the original + const mappedStack = `ReferenceError: z is not defined + at vm:module(0):1:${src.indexOf('z')} + at fn (vm:module(0):1:${src.indexOf('x')})`; + + t.is(error.stack, mappedStack); +}); + +test("should preserve stack trace positions if it can't map them", async (t) => { + const src = `fn((x) => z)`; + + // compile the code with a source map + const { map, code } = compile(src); + + // create a fake job + const job = { + expression: code, + sourceMap: map, + }; + + const error = new RTError(); + (error as any).pos = { + line: 2, + column: 2, + }; + + error.stack = `ReferenceError: z is not defined + at @openfn/language-http_6.5.1/dist/index.cjs:201:22 + at fn ( @openfn/language-http_6.5.1/dist/index.cjs):1192:7`; + + const mappedStack = error.stack; + + await mapErrors(job, error); + t.log(error.stack); + + t.is(error.stack, mappedStack); +}); diff --git a/packages/ws-worker/CHANGELOG.md b/packages/ws-worker/CHANGELOG.md index 2e60591cf..85fbe81e2 100644 --- a/packages/ws-worker/CHANGELOG.md +++ b/packages/ws-worker/CHANGELOG.md @@ -1,5 +1,21 @@ # ws-worker +## 1.9.0 + +### Minor Changes + +- 4ddd4d6: Update errors to include a source-mapped position and more dignostic information + +### Patch Changes + +- Updated dependencies [4ddd4d6] +- Updated dependencies [6e87156] +- Updated dependencies [aaa7e7b] + - @openfn/runtime@1.6.0 + - @openfn/logger@1.0.4 + - @openfn/engine-multi@1.4.7 + - @openfn/lexicon@1.1.0 + ## 1.8.9 ### Patch Changes diff --git a/packages/ws-worker/package.json b/packages/ws-worker/package.json index 49f0b5ddc..769497d4a 100644 --- a/packages/ws-worker/package.json +++ b/packages/ws-worker/package.json @@ -1,6 +1,6 @@ { "name": "@openfn/ws-worker", - "version": "1.8.9", + "version": "1.9.0-rc1", "description": "A Websocket Worker to connect Lightning to a Runtime Engine", "main": "dist/index.js", "type": "module", @@ -58,4 +58,4 @@ "README.md", "CHANGELOG.md" ] -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8ea34ffb..f6c47e56d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -266,7 +266,7 @@ importers: version: 2.8.1 tsup: specifier: ^7.2.0 - version: 7.3.0(@swc/core@1.10.1)(typescript@5.7.2) + version: 7.3.0(@swc/core@1.10.1)(ts-node@10.9.1)(typescript@5.7.2) typescript: specifier: ^5.1.6 version: 5.7.2 @@ -276,6 +276,9 @@ importers: '@openfn/describe-package': specifier: workspace:* version: link:../describe-package + '@openfn/lexicon': + specifier: workspace:^ + version: link:../lexicon '@openfn/logger': specifier: workspace:* version: link:../logger @@ -303,7 +306,7 @@ importers: version: 2.8.1 tsup: specifier: ^7.2.0 - version: 7.3.0(@swc/core@1.10.1)(typescript@5.7.2) + version: 7.3.0(@swc/core@1.10.1)(ts-node@10.9.1)(typescript@5.7.2) typescript: specifier: ^5.1.6 version: 5.7.2 @@ -358,7 +361,7 @@ importers: version: 2.8.1 tsup: specifier: ^7.2.0 - version: 7.3.0(@swc/core@1.10.1)(typescript@5.7.2) + version: 7.3.0(@swc/core@1.10.1)(ts-node@10.9.1)(typescript@5.7.2) typescript: specifier: ^5.1.6 version: 5.7.2 @@ -410,7 +413,7 @@ importers: version: 2.3.0 tsup: specifier: ^7.2.0 - version: 7.3.0(@swc/core@1.10.1)(typescript@5.7.2) + version: 7.3.0(@swc/core@1.10.1)(ts-node@10.9.1)(typescript@5.7.2) packages/engine-multi: dependencies: @@ -447,7 +450,7 @@ importers: version: 2.3.0 tsup: specifier: ^7.2.0 - version: 7.3.0(@swc/core@1.10.1)(typescript@5.7.2) + version: 7.3.0(@swc/core@1.10.1)(ts-node@10.9.1)(typescript@5.7.2) typescript: specifier: ^5.1.6 version: 5.7.2 @@ -465,6 +468,10 @@ importers: version: 6.2.0 packages/lexicon: + dependencies: + source-map: + specifier: ^0.7.4 + version: 0.7.4 devDependencies: '@openfn/logger': specifier: workspace:^ @@ -593,7 +600,7 @@ importers: version: 2.8.1 tsup: specifier: ^7.2.0 - version: 7.3.0(@swc/core@1.10.1)(typescript@5.7.2) + version: 7.3.0(@swc/core@1.10.1)(ts-node@10.9.1)(typescript@5.7.2) typescript: specifier: ^5.1.6 version: 5.7.2 @@ -609,7 +616,13 @@ importers: semver: specifier: ^7.5.4 version: 7.6.3 + source-map: + specifier: ^0.7.4 + version: 0.7.4 devDependencies: + '@openfn/compiler': + specifier: workspace:^ + version: link:../compiler '@openfn/language-common': specifier: 2.0.0-rc3 version: 2.0.0-rc3 @@ -631,12 +644,18 @@ importers: mock-fs: specifier: ^5.4.1 version: 5.4.1 + recast: + specifier: ^0.21.5 + version: 0.21.5 + ts-node: + specifier: ^10.9.1 + version: 10.9.1(@swc/core@1.10.1)(@types/node@18.19.68)(typescript@5.7.2) tslib: specifier: ^2.4.0 version: 2.8.1 tsup: specifier: ^7.2.0 - version: 7.3.0(@swc/core@1.10.1)(typescript@5.7.2) + version: 7.3.0(@swc/core@1.10.1)(ts-node@10.9.1)(typescript@5.7.2) typescript: specifier: ^5.1.6 version: 5.7.2 @@ -941,6 +960,13 @@ packages: prettier: 2.8.8 dev: true + /@cspotcode/source-map-support@0.8.1: + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + dev: true + /@emnapi/core@1.3.1: resolution: {integrity: sha512-pVGjBIt1Y6gg3EJN8jTcfpP/+uuRksIo055oE/OBkDNcjZqVbfkWCksG1Jp4yZnj3iKWyWX8fdG/j6UDYPbFog==} requiresBuild: true @@ -1837,6 +1863,13 @@ packages: '@jridgewell/sourcemap-codec': 1.5.0 dev: true + /@jridgewell/trace-mapping@0.3.9: + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 + dev: true + /@jsep-plugin/assignment@1.3.0(jsep@1.4.0): resolution: {integrity: sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==} engines: {node: '>= 10.16.0'} @@ -1968,6 +2001,7 @@ packages: /@openfn/language-common@2.0.0-rc3: resolution: {integrity: sha512-7kwhBnCd1idyTB3MD9dXmUqROAhoaUIkz2AGDKuv9vn/cbZh7egEv9/PzKkRcDJYFV9qyyS+cVT3Xbgsg2ii5g==} + bundledDependencies: [] /@openfn/language-common@2.0.1: resolution: {integrity: sha512-eiBcgjEzRrZL/sr/ULK/GUQSzktvThbAoorWDM3nXHq22d4OAJkePfFPY4mb7xveCKDNBADTWY39j7CgPiI9Jw==} @@ -2472,6 +2506,22 @@ packages: tailwindcss: 3.4.16 dev: true + /@tsconfig/node10@1.0.11: + resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} + dev: true + + /@tsconfig/node12@1.0.11: + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + dev: true + + /@tsconfig/node14@1.0.3: + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + dev: true + + /@tsconfig/node16@1.0.4: + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + dev: true + /@tybys/wasm-util@0.9.0: resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} requiresBuild: true @@ -2887,6 +2937,10 @@ packages: normalize-path: 3.0.0 picomatch: 2.3.1 + /arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + dev: true + /arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} dev: true @@ -2929,7 +2983,6 @@ packages: engines: {node: '>=4'} dependencies: tslib: 2.8.1 - dev: false /async-sema@3.1.1: resolution: {integrity: sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==} @@ -3602,6 +3655,10 @@ packages: typescript: 5.7.2 dev: true + /create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + dev: true + /cross-fetch@3.1.8: resolution: {integrity: sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==} dependencies: @@ -3767,6 +3824,11 @@ packages: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} dev: true + /diff@4.0.2: + resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} + engines: {node: '>=0.3.1'} + dev: true + /dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -5242,6 +5304,10 @@ packages: /lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + /make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + dev: true + /make-fetch-happen@13.0.1: resolution: {integrity: sha512-cKTUFc/rbKUd/9meOvgrpJ2WrNzymt6jfRDdwg5UCnVzv9dTpEj9JS5m3wtziXVCjluIXyL8pcaukYqezIzZQA==} engines: {node: ^16.14.0 || >=18.0.0} @@ -6015,6 +6081,23 @@ packages: yaml: 2.6.1 dev: true + /postcss-load-config@4.0.2(ts-node@10.9.1): + resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} + engines: {node: '>= 14'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + dependencies: + lilconfig: 3.1.3 + ts-node: 10.9.1(@swc/core@1.10.1)(@types/node@18.19.68)(typescript@5.7.2) + yaml: 2.6.1 + dev: true + /postcss-nested@6.2.0(postcss@8.4.49): resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} engines: {node: '>=12.0'} @@ -6216,7 +6299,6 @@ packages: esprima: 4.0.1 source-map: 0.6.1 tslib: 2.8.1 - dev: false /regenerator-runtime@0.14.1: resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} @@ -6493,6 +6575,11 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + /source-map@0.7.4: + resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} + engines: {node: '>= 8'} + dev: false + /source-map@0.8.0-beta.0: resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} engines: {node: '>= 8'} @@ -6846,6 +6933,38 @@ packages: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} dev: true + /ts-node@10.9.1(@swc/core@1.10.1)(@types/node@18.19.68)(typescript@5.7.2): + resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@swc/core': 1.10.1 + '@tsconfig/node10': 1.0.11 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 18.19.68 + acorn: 8.14.0 + acorn-walk: 8.3.4 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.7.2 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + dev: true + /tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -6899,7 +7018,7 @@ packages: - ts-node dev: true - /tsup@7.3.0(@swc/core@1.10.1)(typescript@5.7.2): + /tsup@7.3.0(@swc/core@1.10.1)(ts-node@10.9.1)(typescript@5.7.2): resolution: {integrity: sha512-Ja1eaSRrE+QarmATlNO5fse2aOACYMBX+IZRKy1T+gpyH+jXgRrl5l4nHIQJQ1DoDgEjHDTw8cpE085UdBZuWQ==} engines: {node: '>=18'} deprecated: Breaking node 16 @@ -6925,7 +7044,7 @@ packages: execa: 5.1.1 globby: 11.1.0 joycon: 3.1.1 - postcss-load-config: 4.0.2(postcss@8.4.49) + postcss-load-config: 4.0.2(ts-node@10.9.1) resolve-from: 5.0.0 rollup: 4.28.1 source-map: 0.8.0-beta.0 @@ -7054,6 +7173,10 @@ packages: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} dev: true + /v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + dev: true + /validate-npm-package-name@5.0.1: resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -7217,6 +7340,11 @@ packages: engines: {node: '>= 4.0.0'} dev: false + /yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + dev: true + /yocto-queue@1.1.1: resolution: {integrity: sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==} engines: {node: '>=12.20'}