From 6473b4aca2d526871b2673a328fe07411a2c043c Mon Sep 17 00:00:00 2001 From: Emmanuel Evance Date: Thu, 4 Jun 2026 17:59:36 +0300 Subject: [PATCH 1/9] cli: add openfn compile --test for unit testing job code - Add --test flag: compiles job expressions for unit testing, writing output to tests/ by default (reads dirs.tests from openfn.yaml) - Add --strip (default on with --test): tree-shakes compiled output, keeping only export const/function declarations and their transitive dependencies; use --no-strip to keep all compiled code - Add --watch flag: watches source files and recompiles on change (uses chokidar) - Strip mode removes injected _defer import when operations are stripped - Skip writing files with no exportable code after stripping; log when skipping - Auto-clean stale step files in tests/ that were skipped in the current run without touching user-added files at other paths - Fix --test --no-strip skipping pure-operation jobs (hasExportableCode guard now only applies when strip is active) Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/package.json | 1 + packages/cli/src/compile/command.ts | 33 +++- packages/cli/src/compile/compile.ts | 87 +++++++++ packages/cli/src/compile/handler.ts | 106 ++++++++++- packages/cli/src/options.ts | 32 ++++ packages/cli/test/compile/compile.test.ts | 147 +++++++++++++++ packages/cli/test/compile/handler.test.ts | 84 +++++++++ .../src/transforms/top-level-operations.ts | 112 ++++++++++- packages/compiler/test/compile.test.ts | 50 +++++ .../transforms/top-level-operations.test.ts | 174 ++++++++++++++++++ 10 files changed, 811 insertions(+), 15 deletions(-) create mode 100644 packages/cli/test/compile/handler.test.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 78dca3c84..184dff1b8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -60,6 +60,7 @@ "@openfn/project": "workspace:^", "@openfn/runtime": "workspace:*", "chalk": "^5.6.2", + "chokidar": "^3.6.0", "dotenv": "^17.3.1", "dotenv-expand": "^12.0.3", "figures": "^5.0.0", diff --git a/packages/cli/src/compile/command.ts b/packages/cli/src/compile/command.ts index b0f9029ac..dbc23ae04 100644 --- a/packages/cli/src/compile/command.ts +++ b/packages/cli/src/compile/command.ts @@ -18,7 +18,10 @@ export type CompileOptions = Pick< | 'path' | 'useAdaptorsMonorepo' | 'globals' + | 'test' + | 'strip' | 'trace' + | 'watch' > & { workflow?: Opts['workflow']; repoDir?: string; @@ -36,22 +39,24 @@ const options = [ }), o.outputPath, o.repoDir, + o.testFlag, + o.stripFlag, o.trace, o.useAdaptorsMonorepo, + o.watchFlag, o.workflow, ]; const compileCommand: yargs.CommandModule = { command: 'compile [path]', describe: - 'Compile an openfn job or workflow and print or save the resulting JavaScript.', + 'Compile an openfn job, workflow, or whole project and print or save the resulting JavaScript.', handler: ensure('compile', options), builder: (yargs) => build(options, yargs) .positional('path', { describe: - 'The path to load the job or workflow from (a .js or .json file or a dir containing a job.js file)', - demandOption: true, + 'Path to a .js expression, .json/.yaml workflow, or a project directory. Omit to compile all workflows in the current project.', }) .example( 'compile foo/job.js', @@ -59,7 +64,27 @@ const compileCommand: yargs.CommandModule = { ) .example( 'compile foo/workflow.json -o foo/workflow-compiled.json', - 'Compiles the workflow at foo/work.json and prints the result to -o foo/workflow-compiled.json' + 'Compiles the workflow and writes to the given path' + ) + .example( + 'compile', + 'Compiles all workflows in the current project and writes JS files to tests/' + ) + .example( + 'compile foo/job.js --test', + 'Strips adaptor operation calls and writes to tests/ (for unit testing)' + ) + .example( + 'compile foo/job.js --test --no-strip', + 'Compiles for testing without stripping operation calls' + ) + .example( + 'compile foo/job.js --watch', + 'Watches the file and recompiles on every change' + ) + .example( + 'compile --test --watch', + 'Compiles all workflows in strip mode and recompiles on change' ), }; diff --git a/packages/cli/src/compile/compile.ts b/packages/cli/src/compile/compile.ts index 822188702..48e11caf0 100644 --- a/packages/cli/src/compile/compile.ts +++ b/packages/cli/src/compile/compile.ts @@ -1,3 +1,5 @@ +import path from 'node:path'; +import fs from 'node:fs/promises'; import compile, { preloadAdaptorExports, Options, @@ -5,6 +7,7 @@ import compile, { } from '@openfn/compiler'; import { getModulePath, type ExecutionPlan, type Job } from '@openfn/runtime'; import type { SourceMapWithOperations } from '@openfn/lexicon'; +import { Workspace } from '@openfn/project'; import createLogger, { COMPILER, Logger } from '../util/logger'; import abort from '../util/abort'; @@ -140,6 +143,11 @@ export const loadTransformOptions = async ( logger: log || createLogger(COMPILER, opts as any), trace: opts.trace, }; + + if (opts.test && opts.strip !== false) { + // Strip top-level operation calls instead of moving them to the export array + options['top-level-operations'] = { strip: true } as any; + } // If an adaptor is passed in, we need to look up its declared exports // and pass them along to the compiler if (opts.adaptors?.length && opts.ignoreImports != true) { @@ -179,3 +187,82 @@ export const loadTransformOptions = async ( return options; }; + +// Compile all steps across all workflows in the current project. +// Writes one .js file per step to compiledDir//.js +export const compileProject = async ( + opts: CompileOptions, + log: Logger, + cwd = process.cwd() +): Promise => { + // validate=false suppresses warnings when workspace config has no extra metadata + const workspace = new Workspace(cwd, log as any, false); + const project = await workspace.getCheckedOutProject(); + + if (!project) { + log.error( + 'No project found. Run from a directory containing openfn.yaml, or provide a path.' + ); + process.exit(1); + } + + const wsConfig = workspace.getConfig() as any; + const compiledDir = path.resolve( + cwd, + opts.outputPath ?? (opts.test ? wsConfig.dirs?.tests ?? 'tests' : wsConfig.dirs?.compiled ?? 'compiled') + ); + + log.info(`Compiling project to ${compiledDir}`); + + const outPaths: string[] = []; + const stalePaths: string[] = []; + + for (const workflow of project.workflows) { + for (const step of workflow.steps) { + const expression = (step as any).expression; + if (!expression || typeof expression !== 'string') continue; + + const adaptor: string | undefined = + (step as any).adaptor ?? (step as any).adaptors?.[0]; + const stepOpts: CompileOptions = { + ...opts, + adaptors: adaptor ? [adaptor] : opts.adaptors ?? [], + }; + + const { code } = await compileJob( + expression, + stepOpts, + log, + (step as any).name ?? step.id + ); + + const outPath = path.join(compiledDir, workflow.id, `${step.id}.js`); + + if (opts.test && opts.strip !== false && !/^\s*(export\s+(const|let|var|function|class)|const|let|var|function|class)\s/m.test(code)) { + log.info(` ${workflow.id}/${step.id} — skipped (no exportable code after stripping)`); + stalePaths.push(outPath); + continue; + } + + await fs.mkdir(path.dirname(outPath), { recursive: true }); + await fs.writeFile(outPath, code); + + outPaths.push(outPath); + log.success(` ${workflow.id}/${step.id} → ${outPath}`); + } + } + + // Remove stale step files left over from a previous run with different flags. + // Only deletes files at exact step paths — user-added files with other names are untouched. + for (const stalePath of stalePaths) { + try { + await fs.unlink(stalePath); + log.info(` Removed stale ${stalePath}`); + } catch (e: any) { + if (e.code !== 'ENOENT') throw e; + } + } + + log.success(`Compiled ${outPaths.length} step(s) to ${compiledDir}`); + return outPaths; +}; diff --git a/packages/cli/src/compile/handler.ts b/packages/cli/src/compile/handler.ts index c92c62f80..d31fca0ef 100644 --- a/packages/cli/src/compile/handler.ts +++ b/packages/cli/src/compile/handler.ts @@ -1,15 +1,54 @@ -import { writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { writeFile, mkdir } from 'node:fs/promises'; +import chokidar from 'chokidar'; +import { Workspace } from '@openfn/project'; import type { CompileOptions } from './command'; import type { Logger } from '../util/logger'; -import compile from './compile'; +import compile, { compileProject } from './compile'; import loadPlan from '../util/load-plan'; import assertPath from '../util/assert-path'; -const compileHandler = async (options: CompileOptions, logger: Logger) => { - assertPath(options.path); +// Returns false when compiled strip output has no declarations worth importing in tests. +export const hasExportableCode = (code: string): boolean => + /^\s*(export\s+(const|let|var|function|class)|const|let|var|function|class)\s/m.test(code); + +export const deriveTestOutputPath = ( + expressionPath: string, + compiledDir: string, + workflowsDir: string, + cwd = process.cwd() +): string => { + const absInput = path.resolve(cwd, expressionPath); + const absWorkflows = path.resolve(cwd, workflowsDir); + if (absInput.startsWith(absWorkflows + path.sep)) { + return path.resolve(cwd, compiledDir, path.relative(absWorkflows, absInput)); + } + return path.resolve(cwd, compiledDir, path.basename(expressionPath)); +}; - let result; +// Derive the watch target(s) from the compile options. +// Returns either file paths or directory globs that chokidar can watch. +const collectWatchTargets = (options: CompileOptions): string[] => { + if (options.expressionPath) { + return [path.resolve(options.expressionPath)]; + } + if (options.path) { + // Workflow/plan mode: watch the workflow file + return [path.resolve(options.path)]; + } + // Project mode: watch the entire workflows directory for .js changes + return [path.join(process.cwd(), 'workflows', '**', '*.js')]; +}; + +// Run a single compilation pass and write output based on options. +const doCompile = async (options: CompileOptions, logger: Logger) => { + if (!options.path) { + await compileProject(options, logger); + return; + } + + let result: string; if (options.expressionPath) { const { code } = await compile(options.expressionPath, options, logger); result = code; @@ -19,12 +58,63 @@ const compileHandler = async (options: CompileOptions, logger: Logger) => { result = JSON.stringify(compiledPlan, null, 2); } - if (options.outputStdout) { + let outputPath = options.outputPath; + let outputStdout = options.outputStdout; + + if (options.test && outputStdout && options.expressionPath) { + const cwd = process.cwd(); + const workspace = new Workspace(cwd, logger as any, false); + const wsConfig = workspace.getConfig() as any; + const compiledDir = wsConfig?.dirs?.tests ?? 'tests'; + const workflowsDir = wsConfig?.dirs?.workflows ?? 'workflows'; + outputPath = deriveTestOutputPath(options.expressionPath, compiledDir, workflowsDir, cwd); + outputStdout = false; + } + + if (options.test && options.strip !== false && !hasExportableCode(result)) { + logger.info(`Skipped ${outputPath ?? options.expressionPath} — no exportable code after stripping`); + return; + } + + if (outputStdout) { logger.success('Result:\n\n' + result); } else { - await writeFile(options.outputPath!, result as string); - logger.success(`Compiled to ${options.outputPath}`); + await mkdir(path.dirname(outputPath!), { recursive: true }); + await writeFile(outputPath!, result); + logger.success(`Compiled to ${outputPath}`); } }; +const compileHandler = async (options: CompileOptions, logger: Logger) => { + if (!options.path) { + // Project mode: no path given, compile all workflows in the current project + await compileProject(options, logger); + } else { + assertPath(options.path); + await doCompile(options, logger); + } + + if (!options.watch) return; + + const watchTargets = collectWatchTargets(options); + logger.info(`Watching for changes. Ctrl+C to stop.`); + + const watcher = chokidar.watch(watchTargets, { + ignoreInitial: true, + ignored: ['**/node_modules/**', '**/tests/**', '**/compiled/**'], + }); + + watcher.on('change', async (changedPath) => { + logger.info(`${changedPath} changed, recompiling...`); + try { + await doCompile(options, logger); + } catch (e) { + logger.error('Compilation error:', e); + } + }); + + // Keep the process alive + await new Promise(() => {}); +}; + export default compileHandler; diff --git a/packages/cli/src/options.ts b/packages/cli/src/options.ts index a97df63b1..6d532a3d9 100644 --- a/packages/cli/src/options.ts +++ b/packages/cli/src/options.ts @@ -66,8 +66,11 @@ export type Opts = { statePath?: string; stateStdin?: string; timeout?: number; // ms + test?: boolean; + strip?: boolean; trace?: boolean; useAdaptorsMonorepo?: boolean; + watch?: boolean; workflow: string; workflowName?: string; validate?: boolean; @@ -623,6 +626,35 @@ export const validate: CLIOption = { }, }; +export const testFlag: CLIOption = { + name: 'test', + yargs: { + boolean: true, + description: + 'Compile for unit testing: writes output to disk and strips adaptor operation calls by default', + default: false, + }, +}; + +export const stripFlag: CLIOption = { + name: 'strip', + yargs: { + boolean: true, + description: + 'Used with --test: strip adaptor operation calls from compiled output (default). Pass --no-strip to keep them.', + }, +}; + +export const watchFlag: CLIOption = { + name: 'watch', + yargs: { + alias: ['w'], + boolean: true, + description: 'Watch source files and recompile on change', + default: false, + }, +}; + export const workflow: CLIOption = { name: 'workflow', yargs: { diff --git a/packages/cli/test/compile/compile.test.ts b/packages/cli/test/compile/compile.test.ts index 1265fb44a..da8300a07 100644 --- a/packages/cli/test/compile/compile.test.ts +++ b/packages/cli/test/compile/compile.test.ts @@ -7,6 +7,7 @@ import compile, { stripVersionSpecifier, loadTransformOptions, resolveSpecifierPath, + compileProject, } from '../../src/compile/compile'; import { CompileOptions } from '../../src/compile/command'; import { mockFs, resetMockFs } from '../util'; @@ -344,3 +345,149 @@ test.serial('loadTransformOptions: ignore some imports', async (t) => { }); // TODO test exception if the module can't be found + +test.serial('loadTransformOptions: test flag sets strip mode', async (t) => { + const opts = { test: true } as CompileOptions; + const result = await loadTransformOptions(opts, mockLog); + t.deepEqual(result['top-level-operations'], { strip: true }); +}); + +test.serial('loadTransformOptions: --no-strip disables strip mode', async (t) => { + const opts = { test: true, strip: false } as CompileOptions; + const result = await loadTransformOptions(opts, mockLog); + t.falsy(result['top-level-operations']); +}); + +test.serial( + 'loadTransformOptions: test flag does not affect other options', + async (t) => { + const opts = { test: true } as CompileOptions; + const result = await loadTransformOptions(opts, mockLog); + t.falsy(result['add-imports']); + } +); + +test.serial( + 'compileProject: compiles all workflow steps and writes output files', + async (t) => { + const pnpm = path.resolve('../../node_modules/.pnpm'); + const recastPath = `${pnpm}/recast@0.21.5`; + const sourceMapPath = `${pnpm}/source-map@0.7.6`; + + mock({ + [recastPath]: mock.load(recastPath, {}), + [sourceMapPath]: mock.load(sourceMapPath, {}), + '/proj/openfn.yaml': ` +dirs: + workflows: /proj/workflows +`, + '/proj/workflows/wf1/wf1.yaml': ` +id: wf1 +steps: + - id: step-a + expression: "x();" +`, + }); + + const outPaths = await compileProject( + {} as CompileOptions, + mockLog, + '/proj' + ); + + t.is(outPaths.length, 1); + t.true(outPaths[0].endsWith('compiled/wf1/step-a.js')); + + const { default: nodeFsPromises } = await import('node:fs/promises'); + const code = await nodeFsPromises.readFile(outPaths[0], 'utf-8'); + t.true(code.includes('export default [x()]')); + + mock.restore(); + } +); + +test.serial( + 'compileProject: removes stale step files skipped in current run', + async (t) => { + const pnpm = path.resolve('../../node_modules/.pnpm'); + const recastPath = `${pnpm}/recast@0.21.5`; + const sourceMapPath = `${pnpm}/source-map@0.7.6`; + + // step-b has no exported code and will be skipped in strip mode. + // Pre-populate a stale file at its expected output path. + mock({ + [recastPath]: mock.load(recastPath, {}), + [sourceMapPath]: mock.load(sourceMapPath, {}), + '/proj/openfn.yaml': ` +dirs: + workflows: /proj/workflows +`, + '/proj/workflows/wf1/wf1.yaml': ` +id: wf1 +steps: + - id: step-a + expression: "export const helper = () => {}; fn();" + - id: step-b + expression: "fn();" +`, + // stale file from a previous --no-strip run + '/proj/tests/wf1/step-b.js': 'export default [fn()];', + // user-added test file — must not be touched + '/proj/tests/wf1/step-b.test.js': 'import { } from "./step-b.js";', + }); + + await compileProject( + { test: true } as CompileOptions, + mockLog, + '/proj' + ); + + const { default: nodeFsPromises } = await import('node:fs/promises'); + + // Stale step file should be gone + await t.throwsAsync(() => nodeFsPromises.readFile('/proj/tests/wf1/step-b.js'), { + code: 'ENOENT', + }); + + // User test file must still exist + const userFile = await nodeFsPromises.readFile('/proj/tests/wf1/step-b.test.js', 'utf-8'); + t.truthy(userFile); + + mock.restore(); + } +); + +test.serial( + 'compileProject: respects outputPath as the compiled directory', + async (t) => { + const pnpm = path.resolve('../../node_modules/.pnpm'); + const recastPath = `${pnpm}/recast@0.21.5`; + const sourceMapPath = `${pnpm}/source-map@0.7.6`; + + mock({ + [recastPath]: mock.load(recastPath, {}), + [sourceMapPath]: mock.load(sourceMapPath, {}), + '/proj/openfn.yaml': ` +dirs: + workflows: /proj/workflows +`, + '/proj/workflows/wf1/wf1.yaml': ` +id: wf1 +steps: + - id: step-a + expression: "x();" +`, + }); + + const outPaths = await compileProject( + { outputPath: '/out' } as CompileOptions, + mockLog, + '/proj' + ); + + t.is(outPaths.length, 1); + t.true(outPaths[0].startsWith('/out/')); + + mock.restore(); + } +); diff --git a/packages/cli/test/compile/handler.test.ts b/packages/cli/test/compile/handler.test.ts new file mode 100644 index 000000000..cc28ec711 --- /dev/null +++ b/packages/cli/test/compile/handler.test.ts @@ -0,0 +1,84 @@ +import test from 'ava'; +import path from 'node:path'; +import { deriveTestOutputPath, hasExportableCode } from '../../src/compile/handler'; + +const cwd = '/project'; + +// deriveTestOutputPath +test('path inside workflows dir → compiled/', (t) => { + const result = deriveTestOutputPath( + 'workflows/dhis2-sync/transform.js', + 'compiled', + 'workflows', + cwd + ); + t.is(result, path.resolve(cwd, 'compiled/dhis2-sync/transform.js')); +}); + +test('path outside workflows dir → compiled/', (t) => { + const result = deriveTestOutputPath( + 'scripts/helper.js', + 'compiled', + 'workflows', + cwd + ); + t.is(result, path.resolve(cwd, 'compiled/helper.js')); +}); + +test('respects custom compiledDir', (t) => { + const result = deriveTestOutputPath( + 'workflows/step.js', + 'dist/compiled', + 'workflows', + cwd + ); + t.is(result, path.resolve(cwd, 'dist/compiled/step.js')); +}); + +test('respects custom workflowsDir', (t) => { + const result = deriveTestOutputPath( + 'jobs/my-workflow/step.js', + 'compiled', + 'jobs', + cwd + ); + t.is(result, path.resolve(cwd, 'compiled/my-workflow/step.js')); +}); + +test('preserves nested subdirectory structure', (t) => { + const result = deriveTestOutputPath( + 'workflows/wf-a/subdir/step.js', + 'compiled', + 'workflows', + cwd + ); + t.is(result, path.resolve(cwd, 'compiled/wf-a/subdir/step.js')); +}); + +// hasExportableCode +test('hasExportableCode: returns true for const declaration', (t) => { + t.true(hasExportableCode("import x from 'y';\nconst helper = () => {};\nexport default [];")); +}); + +test('hasExportableCode: returns true for function declaration', (t) => { + t.true(hasExportableCode("function formatDate(d) { return d; }\nexport default [];")); +}); + +test('hasExportableCode: returns true for exported const', (t) => { + t.true(hasExportableCode("export const VALUE = 42;\nexport default [];")); +}); + +test('hasExportableCode: returns false when only imports and export default []', (t) => { + t.false(hasExportableCode("import { get } from '@openfn/language-http';\nexport default [];")); +}); + +test('hasExportableCode: returns false for empty export default []', (t) => { + t.false(hasExportableCode('export default [];')); +}); + +test('hasExportableCode: returns false for export default with operations (no-strip output)', (t) => { + // With --no-strip, operations land in export default [...] which has no declarations. + // The skip check must not run in this case — this is tested at the handler level, + // but the function itself should return false for this shape. + t.false(hasExportableCode("import { post } from '@openfn/language-http';\nexport default [post('/endpoint')];")); +}); diff --git a/packages/compiler/src/transforms/top-level-operations.ts b/packages/compiler/src/transforms/top-level-operations.ts index a71263e5b..6d96900b9 100644 --- a/packages/compiler/src/transforms/top-level-operations.ts +++ b/packages/compiler/src/transforms/top-level-operations.ts @@ -16,10 +16,70 @@ export type ExtendedProgram = NodePath< export type TopLevelOpsOptions = { // Wrap operations in a `(state) => op` wrapper - wrap: boolean; // TODO + wrap?: boolean; // TODO + // Strip operations instead of moving them into the export array (for test compilation) + strip?: boolean; }; -function visitor(programPath: ExtendedProgram) { +// Recursively collect all Identifier names referenced in a node. +// Conservative: includes property names in member expressions (avoids false negatives). +export const collectRefs = (node: any, refs = new Set()): Set => { + if (!node || typeof node !== 'object') return refs; + if (Array.isArray(node)) { + node.forEach(item => collectRefs(item, refs)); + return refs; + } + if (n.Identifier.check(node)) refs.add(node.name); + for (const key of Object.keys(node)) { + if (key === 'type' || key === 'loc' || key === 'comments' || key === 'tokens') continue; + collectRefs(node[key], refs); + } + return refs; +}; + +// Build a map from declared name → statement for non-export top-level declarations. +export const buildDeclMap = ( + nodes: namedTypes.Statement[] +): Map => { + const map = new Map(); + for (const node of nodes) { + if (n.VariableDeclaration.check(node)) { + for (const d of node.declarations) { + if (n.Identifier.check((d as namedTypes.VariableDeclarator).id)) { + map.set( + ((d as namedTypes.VariableDeclarator).id as namedTypes.Identifier).name, + node + ); + } + } + } else if (n.FunctionDeclaration.check(node) && node.id) { + map.set(node.id.name, node); + } + } + return map; +}; + +// Transitively collect all declarations that the seed nodes depend on. +export const collectDeps = ( + seeds: namedTypes.Statement[], + declMap: Map +): Set => { + const result = new Set(seeds); + const queue = [...seeds]; + while (queue.length) { + const current = queue.shift()!; + for (const ref of collectRefs(current)) { + const dep = declMap.get(ref); + if (dep && !result.has(dep)) { + result.add(dep); + queue.push(dep); + } + } + } + return result; +}; + +function visitor(programPath: ExtendedProgram, _logger: any, options: Partial = {}) { const operations: Array<{ line: number; name: string; order: number }> = []; const children = programPath.node.body; const rem = []; @@ -39,10 +99,56 @@ function visitor(programPath: ExtendedProgram) { const name = child.expression.callee.name; const line = child.expression.loc?.start.line ?? -1; operations.push({ name, line, order }); - target.declaration.elements.push(child.expression as any); + if (!options.strip) { + target.declaration.elements.push(child.expression as any); + } + // In strip mode: operation is neither moved to exports nor kept in body } else rem.push(child); } programPath.node.body = rem; + + if (options.strip) { + // Remove the `defer as _defer` import injected by the promises transform. + // All operations (and thus all _defer usages) have been stripped, so it's dead. + programPath.node.body = programPath.node.body.filter(node => { + if (!n.ImportDeclaration.check(node)) return true; + if (node.source.value !== '@openfn/runtime') return true; + const filtered = (node.specifiers ?? []).filter(s => { + if (!n.ImportSpecifier.check(s)) return true; + return (s.local as namedTypes.Identifier).name !== '_defer'; + }); + if (filtered.length === 0) return false; + node.specifiers = filtered; + return true; + }); + + // Tree-shake: keep only explicitly exported declarations and their dependencies. + // Non-exported top-level declarations with no exported consumer are dropped. + const body = programPath.node.body; + + const exportedDecls = body.filter( + node => + n.ExportNamedDeclaration.check(node) && + (node as namedTypes.ExportNamedDeclaration).declaration != null + ) as namedTypes.Statement[]; + + const nonExported = body.filter( + node => + !n.ImportDeclaration.check(node) && + !n.ExportDefaultDeclaration.check(node) && + !n.ExportNamedDeclaration.check(node) + ); + const declMap = buildDeclMap(nonExported); + + const needed = collectDeps(exportedDecls, declMap); + + programPath.node.body = body.filter( + node => + n.ImportDeclaration.check(node) || + n.ExportDefaultDeclaration.check(node) || + needed.has(node as namedTypes.Statement) + ); + } } else { // error! there isn't an appropriate export statement // What do we do? diff --git a/packages/compiler/test/compile.test.ts b/packages/compiler/test/compile.test.ts index 614e26344..a0e31c267 100644 --- a/packages/compiler/test/compile.test.ts +++ b/packages/compiler/test/compile.test.ts @@ -278,3 +278,53 @@ test('respect ignore list when exports not provided', (t) => { const { code: result } = compile(source, options); t.is(result, expected); }); + +test('strip mode: removes top-level operations, keeps exported JS', (t) => { + const source = [ + 'export const formatDate = (d) => d.toISOString();', + 'get("/api");', + 'fn(state => ({ ...state, date: formatDate(state.data.date) }));', + ].join('\n'); + + const { code: result } = compile(source, { + 'top-level-operations': { strip: true }, + }); + + t.true(result.includes('export const formatDate')); + t.false(result.includes('get(')); + t.false(result.includes('fn(state')); + t.true(result.includes('export default []')); +}); + +test('strip mode: removes non-exported declarations', (t) => { + const source = [ + 'const formatDate = (d) => d.toISOString();', + 'get("/api");', + ].join('\n'); + + const { code: result } = compile(source, { + 'top-level-operations': { strip: true }, + }); + + // non-exported const is dropped by tree-shaking + t.false(result.includes('const formatDate')); + t.false(result.includes('get(')); + t.true(result.includes('export default []')); +}); + +test('strip mode: keeps import statements', (t) => { + const source = [ + 'import { dateFns } from "@openfn/language-common";', + 'export const formatDate = (d) => dateFns.format(d);', + 'get("/api");', + 'export default [];', + ].join('\n'); + + const { code: result } = compile(source, { + 'top-level-operations': { strip: true }, + }); + + t.true(result.includes('import { dateFns }')); + t.true(result.includes('export const formatDate')); + t.false(result.includes('get(')); +}); diff --git a/packages/compiler/test/transforms/top-level-operations.test.ts b/packages/compiler/test/transforms/top-level-operations.test.ts index 3b069a394..5a6ff410f 100644 --- a/packages/compiler/test/transforms/top-level-operations.test.ts +++ b/packages/compiler/test/transforms/top-level-operations.test.ts @@ -217,6 +217,180 @@ 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('strip: removes top-level operations instead of moving them to exports', (t) => { + const ast = createProgramWithExports([ + createOperationStatement('get'), + createOperationStatement('fn'), + ]); + + const { body } = transform(ast, [visitors], { + 'top-level-operations': { strip: true }, + }); + + // Only the export default [] remains + t.is(body.length, 1); + t.true(n.ExportDefaultDeclaration.check(body[0])); + + // Export array is empty — operations were stripped, not moved + const arr = (body[0] as n.ExportDefaultDeclaration).declaration as n.ArrayExpression; + t.is(arr.elements.length, 0); +}); + +test('strip: removes non-exported top-level declarations', (t) => { + // A non-exported const should be dropped — nothing exports it + const ast = createProgramWithExports([ + b.variableDeclaration('const', [ + b.variableDeclarator(b.identifier('x'), b.literal(42)), + ]), + createOperationStatement('get'), + ]); + + const { body } = transform(ast, [visitors], { + 'top-level-operations': { strip: true }, + }); + + // Only export default [] remains — non-exported const is removed + t.is(body.length, 1); + t.true(n.ExportDefaultDeclaration.check(body[0])); + const arr = (body[0] as n.ExportDefaultDeclaration).declaration as n.ArrayExpression; + t.is(arr.elements.length, 0); +}); + +test('strip: keeps exported const declarations', (t) => { + const ast = createProgramWithExports([ + b.exportNamedDeclaration( + b.variableDeclaration('const', [ + b.variableDeclarator(b.identifier('helper'), b.literal(42)), + ]), + [] + ), + createOperationStatement('get'), + ]); + + const { body } = transform(ast, [visitors], { + 'top-level-operations': { strip: true }, + }); + + // export const helper + export default [] + t.is(body.length, 2); + t.true(n.ExportNamedDeclaration.check(body[0])); + t.true(n.ExportDefaultDeclaration.check(body[1])); +}); + +test('strip: keeps non-exported declarations that exported functions depend on', (t) => { + // export function uses a local helper — the helper should be kept + const localHelper = b.variableDeclaration('const', [ + b.variableDeclarator( + b.identifier('fmt'), + b.arrowFunctionExpression([b.identifier('d')], b.identifier('d')) + ), + ]); + const exportedFn = b.exportNamedDeclaration( + b.functionDeclaration( + b.identifier('formatDate'), + [b.identifier('date')], + b.blockStatement([ + b.returnStatement(b.callExpression(b.identifier('fmt'), [b.identifier('date')])), + ]) + ), + [] + ); + + const ast = createProgramWithExports([ + localHelper, + exportedFn, + createOperationStatement('get'), + ]); + + const { body } = transform(ast, [visitors], { + 'top-level-operations': { strip: true }, + }); + + // const fmt + export function formatDate + export default [] + t.is(body.length, 3); + t.true(n.VariableDeclaration.check(body[0])); + t.true(n.ExportNamedDeclaration.check(body[1])); + t.true(n.ExportDefaultDeclaration.check(body[2])); +}); + +test('strip: drops unreferenced non-exported declarations', (t) => { + // localUnused is not referenced by any export — should be dropped + const localUnused = b.variableDeclaration('const', [ + b.variableDeclarator(b.identifier('unused'), b.literal(99)), + ]); + const exportedFn = b.exportNamedDeclaration( + b.functionDeclaration( + b.identifier('greet'), + [], + b.blockStatement([b.returnStatement(b.stringLiteral('hi'))]) + ), + [] + ); + + const ast = createProgramWithExports([ + localUnused, + exportedFn, + createOperationStatement('fn'), + ]); + + const { body } = transform(ast, [visitors], { + 'top-level-operations': { strip: true }, + }); + + // Only export function greet + export default [] — unused const is gone + t.is(body.length, 2); + t.true(n.ExportNamedDeclaration.check(body[0])); + t.true(n.ExportDefaultDeclaration.check(body[1])); +}); + +test('strip: removes injected _defer import from @openfn/runtime', (t) => { + // Simulate AST after the promises transform has injected the _defer import + const deferImport = b.importDeclaration( + [b.importSpecifier(b.identifier('defer'), b.identifier('_defer'))], + b.stringLiteral('@openfn/runtime') + ); + const ast = b.program([ + deferImport, + createOperationStatement('get'), + b.exportDefaultDeclaration(b.arrayExpression([])), + ]); + + const { body } = transform(ast, [visitors], { + 'top-level-operations': { strip: true }, + }) as n.Program; + + // The _defer import should be gone — only export default [] remains + t.is(body.length, 1); + t.true(n.ExportDefaultDeclaration.check(body[0])); +}); + +test('strip: keeps other @openfn/runtime specifiers when removing _defer', (t) => { + // If an import has _defer plus other specifiers, only _defer should be removed + const mixedImport = b.importDeclaration( + [ + b.importSpecifier(b.identifier('defer'), b.identifier('_defer')), + b.importSpecifier(b.identifier('execute'), b.identifier('execute')), + ], + b.stringLiteral('@openfn/runtime') + ); + const ast = b.program([ + mixedImport, + createOperationStatement('get'), + b.exportDefaultDeclaration(b.arrayExpression([])), + ]); + + const { body } = transform(ast, [visitors], { + 'top-level-operations': { strip: true }, + }) as n.Program; + + // Import should remain but without _defer + t.is(body.length, 2); + t.true(n.ImportDeclaration.check(body[0])); + const imp = body[0] as n.ImportDeclaration; + t.is(imp.specifiers?.length, 1); + t.is((imp.specifiers![0] as n.ImportSpecifier).imported.name, 'execute'); +}); + 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(); export default [];`); From 9f0ecd7b9adac5e42ef9a93ca7b5321e08a8f087 Mon Sep 17 00:00:00 2001 From: Emmanuel Evance Date: Thu, 4 Jun 2026 18:02:55 +0300 Subject: [PATCH 2/9] chore: update pnpm-lock.yaml Co-Authored-By: Claude Sonnet 4.6 --- pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa9301662..c2e33ea96 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -191,6 +191,9 @@ importers: chalk: specifier: ^5.6.2 version: 5.6.2 + chokidar: + specifier: ^3.6.0 + version: 3.6.0 dotenv: specifier: ^17.3.1 version: 17.3.1 From 9bbc3bf9d9d769e57440b8c850700f90c177f8b4 Mon Sep 17 00:00:00 2001 From: Emmanuel Evance Date: Thu, 4 Jun 2026 18:27:26 +0300 Subject: [PATCH 3/9] cli: refine compile --test behaviour - remove export default [] from strip mode output (not needed for unit testing) - add --no-strip flag to keep full compiled output including operations - remove --strip standalone flag (stripping is always on with --test by default) - auto-derive output path for single-file --test using tests/ dir - skip writing files with no exportable code in strip mode - auto-clean stale step files in tests/ after project-wide strip runs - fix --test --no-strip skipping pure-operation jobs - update help examples and option descriptions Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/compile/command.ts | 5 ++- packages/cli/src/options.ts | 6 ++- packages/cli/test/compile/compile.test.ts | 1 + packages/cli/test/compile/handler.test.ts | 15 ++----- .../src/transforms/top-level-operations.ts | 1 - packages/compiler/test/compile.test.ts | 6 +-- .../transforms/top-level-operations.test.ts | 45 ++++++------------- 7 files changed, 29 insertions(+), 50 deletions(-) diff --git a/packages/cli/src/compile/command.ts b/packages/cli/src/compile/command.ts index dbc23ae04..1448f5ea9 100644 --- a/packages/cli/src/compile/command.ts +++ b/packages/cli/src/compile/command.ts @@ -75,9 +75,10 @@ const compileCommand: yargs.CommandModule = { 'Strips adaptor operation calls and writes to tests/ (for unit testing)' ) .example( - 'compile foo/job.js --test --no-strip', - 'Compiles for testing without stripping operation calls' + 'compile --test --no-strip', + 'Compiles entire project to tests/ without removing any code' ) + .example( 'compile foo/job.js --watch', 'Watches the file and recompiles on every change' diff --git a/packages/cli/src/options.ts b/packages/cli/src/options.ts index 6d532a3d9..247b09199 100644 --- a/packages/cli/src/options.ts +++ b/packages/cli/src/options.ts @@ -631,7 +631,7 @@ export const testFlag: CLIOption = { yargs: { boolean: true, description: - 'Compile for unit testing: writes output to disk and strips adaptor operation calls by default', + 'Compile for unit testing: strips adaptor operation calls and writes output to tests/', default: false, }, }; @@ -641,10 +641,12 @@ export const stripFlag: CLIOption = { yargs: { boolean: true, description: - 'Used with --test: strip adaptor operation calls from compiled output (default). Pass --no-strip to keep them.', + 'Used with --test: strip operation calls from compiled output. Use --no-strip to keep them.', + default: true, }, }; + export const watchFlag: CLIOption = { name: 'watch', yargs: { diff --git a/packages/cli/test/compile/compile.test.ts b/packages/cli/test/compile/compile.test.ts index da8300a07..e81a46da5 100644 --- a/packages/cli/test/compile/compile.test.ts +++ b/packages/cli/test/compile/compile.test.ts @@ -358,6 +358,7 @@ test.serial('loadTransformOptions: --no-strip disables strip mode', async (t) => t.falsy(result['top-level-operations']); }); + test.serial( 'loadTransformOptions: test flag does not affect other options', async (t) => { diff --git a/packages/cli/test/compile/handler.test.ts b/packages/cli/test/compile/handler.test.ts index cc28ec711..9bf4d5f61 100644 --- a/packages/cli/test/compile/handler.test.ts +++ b/packages/cli/test/compile/handler.test.ts @@ -68,17 +68,10 @@ test('hasExportableCode: returns true for exported const', (t) => { t.true(hasExportableCode("export const VALUE = 42;\nexport default [];")); }); -test('hasExportableCode: returns false when only imports and export default []', (t) => { - t.false(hasExportableCode("import { get } from '@openfn/language-http';\nexport default [];")); +test('hasExportableCode: returns false when only imports', (t) => { + t.false(hasExportableCode("import { get } from '@openfn/language-http';")); }); -test('hasExportableCode: returns false for empty export default []', (t) => { - t.false(hasExportableCode('export default [];')); -}); - -test('hasExportableCode: returns false for export default with operations (no-strip output)', (t) => { - // With --no-strip, operations land in export default [...] which has no declarations. - // The skip check must not run in this case — this is tested at the handler level, - // but the function itself should return false for this shape. - t.false(hasExportableCode("import { post } from '@openfn/language-http';\nexport default [post('/endpoint')];")); +test('hasExportableCode: returns false for empty string', (t) => { + t.false(hasExportableCode('')); }); diff --git a/packages/compiler/src/transforms/top-level-operations.ts b/packages/compiler/src/transforms/top-level-operations.ts index 6d96900b9..7f9d6f2fa 100644 --- a/packages/compiler/src/transforms/top-level-operations.ts +++ b/packages/compiler/src/transforms/top-level-operations.ts @@ -145,7 +145,6 @@ function visitor(programPath: ExtendedProgram, _logger: any, options: Partial n.ImportDeclaration.check(node) || - n.ExportDefaultDeclaration.check(node) || needed.has(node as namedTypes.Statement) ); } diff --git a/packages/compiler/test/compile.test.ts b/packages/compiler/test/compile.test.ts index a0e31c267..47702abf4 100644 --- a/packages/compiler/test/compile.test.ts +++ b/packages/compiler/test/compile.test.ts @@ -293,7 +293,7 @@ test('strip mode: removes top-level operations, keeps exported JS', (t) => { t.true(result.includes('export const formatDate')); t.false(result.includes('get(')); t.false(result.includes('fn(state')); - t.true(result.includes('export default []')); + t.false(result.includes('export default []')); }); test('strip mode: removes non-exported declarations', (t) => { @@ -306,10 +306,10 @@ test('strip mode: removes non-exported declarations', (t) => { 'top-level-operations': { strip: true }, }); - // non-exported const is dropped by tree-shaking + // non-exported const is dropped by tree-shaking; export default [] is also gone t.false(result.includes('const formatDate')); t.false(result.includes('get(')); - t.true(result.includes('export default []')); + t.false(result.includes('export default []')); }); test('strip mode: keeps import statements', (t) => { diff --git a/packages/compiler/test/transforms/top-level-operations.test.ts b/packages/compiler/test/transforms/top-level-operations.test.ts index 5a6ff410f..47aba0584 100644 --- a/packages/compiler/test/transforms/top-level-operations.test.ts +++ b/packages/compiler/test/transforms/top-level-operations.test.ts @@ -227,17 +227,11 @@ test('strip: removes top-level operations instead of moving them to exports', (t 'top-level-operations': { strip: true }, }); - // Only the export default [] remains - t.is(body.length, 1); - t.true(n.ExportDefaultDeclaration.check(body[0])); - - // Export array is empty — operations were stripped, not moved - const arr = (body[0] as n.ExportDefaultDeclaration).declaration as n.ArrayExpression; - t.is(arr.elements.length, 0); + // Nothing remains — operations and export default [] are both removed + t.is(body.length, 0); }); test('strip: removes non-exported top-level declarations', (t) => { - // A non-exported const should be dropped — nothing exports it const ast = createProgramWithExports([ b.variableDeclaration('const', [ b.variableDeclarator(b.identifier('x'), b.literal(42)), @@ -249,11 +243,8 @@ test('strip: removes non-exported top-level declarations', (t) => { 'top-level-operations': { strip: true }, }); - // Only export default [] remains — non-exported const is removed - t.is(body.length, 1); - t.true(n.ExportDefaultDeclaration.check(body[0])); - const arr = (body[0] as n.ExportDefaultDeclaration).declaration as n.ArrayExpression; - t.is(arr.elements.length, 0); + // Nothing remains — non-exported const and export default [] are both removed + t.is(body.length, 0); }); test('strip: keeps exported const declarations', (t) => { @@ -271,14 +262,12 @@ test('strip: keeps exported const declarations', (t) => { 'top-level-operations': { strip: true }, }); - // export const helper + export default [] - t.is(body.length, 2); + // Only export const helper — export default [] is removed + t.is(body.length, 1); t.true(n.ExportNamedDeclaration.check(body[0])); - t.true(n.ExportDefaultDeclaration.check(body[1])); }); test('strip: keeps non-exported declarations that exported functions depend on', (t) => { - // export function uses a local helper — the helper should be kept const localHelper = b.variableDeclaration('const', [ b.variableDeclarator( b.identifier('fmt'), @@ -306,15 +295,13 @@ test('strip: keeps non-exported declarations that exported functions depend on', 'top-level-operations': { strip: true }, }); - // const fmt + export function formatDate + export default [] - t.is(body.length, 3); + // const fmt + export function formatDate — no export default [] + t.is(body.length, 2); t.true(n.VariableDeclaration.check(body[0])); t.true(n.ExportNamedDeclaration.check(body[1])); - t.true(n.ExportDefaultDeclaration.check(body[2])); }); test('strip: drops unreferenced non-exported declarations', (t) => { - // localUnused is not referenced by any export — should be dropped const localUnused = b.variableDeclaration('const', [ b.variableDeclarator(b.identifier('unused'), b.literal(99)), ]); @@ -337,14 +324,12 @@ test('strip: drops unreferenced non-exported declarations', (t) => { 'top-level-operations': { strip: true }, }); - // Only export function greet + export default [] — unused const is gone - t.is(body.length, 2); + // Only export function greet — unused const and export default [] are gone + t.is(body.length, 1); t.true(n.ExportNamedDeclaration.check(body[0])); - t.true(n.ExportDefaultDeclaration.check(body[1])); }); test('strip: removes injected _defer import from @openfn/runtime', (t) => { - // Simulate AST after the promises transform has injected the _defer import const deferImport = b.importDeclaration( [b.importSpecifier(b.identifier('defer'), b.identifier('_defer'))], b.stringLiteral('@openfn/runtime') @@ -359,13 +344,11 @@ test('strip: removes injected _defer import from @openfn/runtime', (t) => { 'top-level-operations': { strip: true }, }) as n.Program; - // The _defer import should be gone — only export default [] remains - t.is(body.length, 1); - t.true(n.ExportDefaultDeclaration.check(body[0])); + // Everything stripped — body is empty + t.is(body.length, 0); }); test('strip: keeps other @openfn/runtime specifiers when removing _defer', (t) => { - // If an import has _defer plus other specifiers, only _defer should be removed const mixedImport = b.importDeclaration( [ b.importSpecifier(b.identifier('defer'), b.identifier('_defer')), @@ -383,8 +366,8 @@ test('strip: keeps other @openfn/runtime specifiers when removing _defer', (t) = 'top-level-operations': { strip: true }, }) as n.Program; - // Import should remain but without _defer - t.is(body.length, 2); + // Only the filtered import remains — _defer removed, execute kept, export default [] gone + t.is(body.length, 1); t.true(n.ImportDeclaration.check(body[0])); const imp = body[0] as n.ImportDeclaration; t.is(imp.specifiers?.length, 1); From 10231650f5029f8c79be8b35544f6e6209ab3f82 Mon Sep 17 00:00:00 2001 From: Emmanuel Evance Date: Thu, 4 Jun 2026 18:32:13 +0300 Subject: [PATCH 4/9] docs: reference unit-testing-jobs.md in additional documentation Co-Authored-By: Claude Sonnet 4.6 --- claude.md | 1 + 1 file changed, 1 insertion(+) diff --git a/claude.md b/claude.md index b2f45c047..c150056ca 100644 --- a/claude.md +++ b/claude.md @@ -111,6 +111,7 @@ The [.claude](.claude) folder contains detailed guides: - **[command-refactor.md](.claude/command-refactor.md)** - Refactoring CLI commands into project subcommand structure - **[event-processor.md](.claude/event-processor.md)** - Worker event processing architecture (batching, ordering) +- **[unit-testing-jobs.md](.claude/unit-testing-jobs.md)** - How to unit-test job code using `openfn compile --test` ## Code Standards From 7c10b6ed5d20926a22efa25e7d0c37f0bccb5a37 Mon Sep 17 00:00:00 2001 From: Emmanuel Evance Date: Thu, 4 Jun 2026 18:32:20 +0300 Subject: [PATCH 5/9] docs: add unit-testing-jobs.md guide for openfn compile --test Co-Authored-By: Claude Sonnet 4.6 --- .claude/unit-testing-jobs.md | 154 +++++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 .claude/unit-testing-jobs.md diff --git a/.claude/unit-testing-jobs.md b/.claude/unit-testing-jobs.md new file mode 100644 index 000000000..546f48c81 --- /dev/null +++ b/.claude/unit-testing-jobs.md @@ -0,0 +1,154 @@ +# Unit Testing Job Code + +OpenFn job expressions are not valid JavaScript out of the box — top-level adaptor calls like `get('/endpoint')` prevent them from being imported directly into a test runner. Compiling them first solves this. + +## Approach + +1. Compile job expressions to standard JavaScript +2. Import compiled files in your test suite +3. Test any pure functions in isolation + +## Compiling for Tests + +The `--test` flag writes compiled output to `tests/` by default. By default it also strips adaptor operation calls, keeping only explicitly exported code. Pass `--no-strip` to keep everything. + +```bash +# Compile a single step (strips operations, writes to tests/) +openfn compile workflows/my-workflow/step-a.js --test + +# Compile to a specific file +openfn compile workflows/my-workflow/step-a.js --test -o tests/step-a.js + +# Compile all workflows in the project +openfn compile --test + +# Compile without stripping — keeps operations and all declarations +openfn compile --test --no-strip + +# Watch mode: recompile whenever source files change +openfn compile --test --watch +``` + +## What survives compilation (strip mode) + +By default `--test` strips the code. Only explicitly exported declarations survive: + +- `export const myHelper = ...` ✓ +- `export function parseSms() {}` ✓ +- `const helper = ...` (not exported) ✗ — dropped +- `fn(state => ...)` (operation call) ✗ — stripped + +If an exported function depends on a non-exported local declaration, that dependency is kept automatically. + +`export default` is removed in strip mode — it is only needed by the runtime, not for unit testing. + +Steps with no exportable code (nothing is exported after stripping) are skipped — no file is written. + +## What `--no-strip` keeps + +With `--no-strip`, the full compiled output is written — all declarations are preserved and operations are kept in `export default [op1, op2, ...]`: + +```js +import { post } from '@openfn/language-http'; + +export default [post('/endpoint', { data: state.data })]; +``` + +All steps are written regardless of whether they export anything. + +## Example: Testing a Helper Function + +**Source** (`workflows/dhis2-sync/transform.js`): + +```js +import { dateFns } from '@openfn/language-dhis2'; + +export const formatDate = (date) => dateFns.format(date, 'yyyy-MM-dd'); + +fn((state) => ({ + ...state, + data: state.data.map((row) => ({ + ...row, + date: formatDate(row.date), + })), +})); +``` + +**Compiled** (`tests/dhis2-sync/transform.js`) after `openfn compile --test`: + +```js +import { dateFns } from '@openfn/language-dhis2'; + +export const formatDate = (date) => dateFns.format(date, 'yyyy-MM-dd'); +``` + +The operation is stripped. `formatDate` survives because it is explicitly exported. + +**Test** (using any test runner): + +```js +// test/transform.test.js +import { formatDate } from '../tests/dhis2-sync/transform.js'; + +test('formats a date correctly', () => { + const result = formatDate(new Date('2024-01-15')); + assert.equal(result, '2024-01-15'); +}); +``` + +## Project-wide Compilation + +Running `openfn compile --test` with no path compiles every step in every workflow in the current project directory (must contain `openfn.yaml`). + +Output layout: + +``` +tests/ + my-workflow/ + step-a.js + step-b.js + another-workflow/ + step-c.js +``` + +Override the output directory with `-o `: + +```bash +openfn compile --test -o dist/tests +``` + +Configure default directories in `openfn.yaml`: + +```yaml +dirs: + workflows: workflows + compiled: compiled # used by openfn compile (without --test) + tests: tests # used by openfn compile --test +``` + +### Stale file cleanup + +After each project-wide run in strip mode, any step file that was skipped (no exportable code) is automatically deleted from `tests/` if it exists from a previous run. Only files at exact step paths (`tests//.js`) are touched — files you have added at other paths (e.g. `tests/my-workflow/helpers.js`) are never removed. + +There is no option to wipe the entire `tests/` directory. To do a full reset, delete it manually before running `openfn compile --test`. + +## Recommended Setup + +**`package.json`** (in your OpenFn project): + +```json +{ + "scripts": { + "compile": "openfn compile --test", + "compile:watch": "openfn compile --test --watch", + "test": "node --test test/**/*.test.js" + } +} +``` + +## Notes + +- In strip mode, only `export const` and `export function` declarations survive — non-exported helpers are dropped unless referenced by an export +- Import statements are always preserved +- `--no-strip` keeps all code including operations in `export default [...]` +- Watch mode reruns compilation on any source change, making the edit → test cycle fast From 82a6d31e27a9d8393a25c869f39b8ef7c93719a5 Mon Sep 17 00:00:00 2001 From: Emmanuel Evance Date: Fri, 5 Jun 2026 11:02:22 +0300 Subject: [PATCH 6/9] compiler/cli: replace --test with --exports-only, add project-wide compile - Drop --test and --strip/--no-strip flags from openfn compile - Add --exports-only flag (opt-in) to strip operation calls, keeping only exported constants and functions for unit testing - openfn compile (no args) now compiles the whole project to compiled/ by default - openfn compile looks up a workflow by name/id and compiles it - Extract stripping logic into a new exports-only transformer (order: 0) that runs before all others; remove strip option from top-level-operations - Update unit-testing-jobs.md to document the new workflow Co-Authored-By: Claude Sonnet 4.6 --- .claude/unit-testing-jobs.md | 57 +++-- packages/cli/src/compile/command.ts | 36 +-- packages/cli/src/compile/compile.ts | 69 +++-- packages/cli/src/compile/handler.ts | 75 ++---- packages/cli/src/options.ts | 20 +- packages/cli/test/compile/compile.test.ts | 61 +++-- packages/cli/test/compile/handler.test.ts | 78 +----- packages/cli/test/compile/options.test.ts | 2 +- packages/compiler/src/transform.ts | 4 + .../compiler/src/transforms/exports-only.ts | 118 +++++++++ .../src/transforms/top-level-operations.ts | 109 +------- packages/compiler/test/compile.test.ts | 26 +- .../test/transforms/exports-only.test.ts | 241 ++++++++++++++++++ .../transforms/top-level-operations.test.ts | 156 ------------ 14 files changed, 546 insertions(+), 506 deletions(-) create mode 100644 packages/compiler/src/transforms/exports-only.ts create mode 100644 packages/compiler/test/transforms/exports-only.test.ts diff --git a/.claude/unit-testing-jobs.md b/.claude/unit-testing-jobs.md index 546f48c81..f782f4ae1 100644 --- a/.claude/unit-testing-jobs.md +++ b/.claude/unit-testing-jobs.md @@ -10,28 +10,31 @@ OpenFn job expressions are not valid JavaScript out of the box — top-level ada ## Compiling for Tests -The `--test` flag writes compiled output to `tests/` by default. By default it also strips adaptor operation calls, keeping only explicitly exported code. Pass `--no-strip` to keep everything. +`openfn compile` writes compiled output to `compiled/` by default. Use `--exports-only` to also strip adaptor operation calls, keeping only explicitly exported code. ```bash -# Compile a single step (strips operations, writes to tests/) -openfn compile workflows/my-workflow/step-a.js --test +# Compile all workflows in the project (full compilation, preserves operations) +openfn compile -# Compile to a specific file -openfn compile workflows/my-workflow/step-a.js --test -o tests/step-a.js +# Compile all workflows, stripping operation calls (useful for unit testing) +openfn compile --exports-only -# Compile all workflows in the project -openfn compile --test +# Compile a single workflow by name +openfn compile my-workflow -# Compile without stripping — keeps operations and all declarations -openfn compile --test --no-strip +# Compile a single workflow to a custom directory +openfn compile my-workflow -o tests/ + +# Compile a single job expression (prints to stdout) +openfn compile workflows/my-workflow/step-a.js --exports-only # Watch mode: recompile whenever source files change -openfn compile --test --watch +openfn compile --exports-only --watch ``` -## What survives compilation (strip mode) +## What `--exports-only` keeps -By default `--test` strips the code. Only explicitly exported declarations survive: +`--exports-only` strips operation calls. Only explicitly exported declarations survive: - `export const myHelper = ...` ✓ - `export function parseSms() {}` ✓ @@ -44,9 +47,9 @@ If an exported function depends on a non-exported local declaration, that depend Steps with no exportable code (nothing is exported after stripping) are skipped — no file is written. -## What `--no-strip` keeps +## Full compilation (no `--exports-only`) -With `--no-strip`, the full compiled output is written — all declarations are preserved and operations are kept in `export default [op1, op2, ...]`: +Without `--exports-only`, the full compiled output is written — all declarations are preserved and operations are kept in `export default [op1, op2, ...]`: ```js import { post } from '@openfn/language-http'; @@ -74,7 +77,7 @@ fn((state) => ({ })); ``` -**Compiled** (`tests/dhis2-sync/transform.js`) after `openfn compile --test`: +**Compiled** (`compiled/dhis2-sync/transform.js`) after `openfn compile --exports-only`: ```js import { dateFns } from '@openfn/language-dhis2'; @@ -88,7 +91,7 @@ The operation is stripped. `formatDate` survives because it is explicitly export ```js // test/transform.test.js -import { formatDate } from '../tests/dhis2-sync/transform.js'; +import { formatDate } from '../compiled/dhis2-sync/transform.js'; test('formats a date correctly', () => { const result = formatDate(new Date('2024-01-15')); @@ -98,12 +101,12 @@ test('formats a date correctly', () => { ## Project-wide Compilation -Running `openfn compile --test` with no path compiles every step in every workflow in the current project directory (must contain `openfn.yaml`). +Running `openfn compile` with no path compiles every step in every workflow in the current project directory (must contain `openfn.yaml`). Output layout: ``` -tests/ +compiled/ my-workflow/ step-a.js step-b.js @@ -114,7 +117,7 @@ tests/ Override the output directory with `-o `: ```bash -openfn compile --test -o dist/tests +openfn compile --exports-only -o tests/ ``` Configure default directories in `openfn.yaml`: @@ -122,15 +125,14 @@ Configure default directories in `openfn.yaml`: ```yaml dirs: workflows: workflows - compiled: compiled # used by openfn compile (without --test) - tests: tests # used by openfn compile --test + compiled: compiled # used by openfn compile ``` ### Stale file cleanup -After each project-wide run in strip mode, any step file that was skipped (no exportable code) is automatically deleted from `tests/` if it exists from a previous run. Only files at exact step paths (`tests//.js`) are touched — files you have added at other paths (e.g. `tests/my-workflow/helpers.js`) are never removed. +After each project-wide run with `--exports-only`, any step file that was skipped (no exportable code) is automatically deleted from `compiled/` if it exists from a previous run. Only files at exact step paths (`compiled//.js`) are touched — files you have added at other paths (e.g. `compiled/my-workflow/helpers.js`) are never removed. -There is no option to wipe the entire `tests/` directory. To do a full reset, delete it manually before running `openfn compile --test`. +There is no option to wipe the entire `compiled/` directory. To do a full reset, delete it manually before running `openfn compile`. ## Recommended Setup @@ -139,8 +141,8 @@ There is no option to wipe the entire `tests/` directory. To do a full reset, de ```json { "scripts": { - "compile": "openfn compile --test", - "compile:watch": "openfn compile --test --watch", + "compile": "openfn compile --exports-only", + "compile:watch": "openfn compile --exports-only --watch", "test": "node --test test/**/*.test.js" } } @@ -148,7 +150,8 @@ There is no option to wipe the entire `tests/` directory. To do a full reset, de ## Notes -- In strip mode, only `export const` and `export function` declarations survive — non-exported helpers are dropped unless referenced by an export +- In `--exports-only` mode, only `export const` and `export function` declarations survive — non-exported helpers are dropped unless referenced by an export - Import statements are always preserved -- `--no-strip` keeps all code including operations in `export default [...]` +- Without `--exports-only`, all code including operations is kept in `export default [...]` - Watch mode reruns compilation on any source change, making the edit → test cycle fast +- Use `-O` to print compiled output to stdout instead of writing to disk diff --git a/packages/cli/src/compile/command.ts b/packages/cli/src/compile/command.ts index 1448f5ea9..73b96ab8c 100644 --- a/packages/cli/src/compile/command.ts +++ b/packages/cli/src/compile/command.ts @@ -1,13 +1,14 @@ import yargs from 'yargs'; import { Opts } from '../options'; import * as o from '../options'; -import { build, ensure, override } from '../util/command-builders'; +import { build, ensure } from '../util/command-builders'; export type CompileOptions = Pick< Opts, | 'adaptors' | 'command' | 'expandAdaptors' + | 'exportsOnly' | 'ignoreImports' | 'expressionPath' | 'logJson' @@ -18,10 +19,9 @@ export type CompileOptions = Pick< | 'path' | 'useAdaptorsMonorepo' | 'globals' - | 'test' - | 'strip' | 'trace' | 'watch' + | 'workflowName' > & { workflow?: Opts['workflow']; repoDir?: string; @@ -30,17 +30,14 @@ export type CompileOptions = Pick< const options = [ o.expandAdaptors, // order important o.adaptors, + o.exportsOnly, o.ignoreImports, o.inputPath, o.log, o.logJson, - override(o.outputStdout, { - default: true, - }), + o.outputStdout, o.outputPath, o.repoDir, - o.testFlag, - o.stripFlag, o.trace, o.useAdaptorsMonorepo, o.watchFlag, @@ -68,24 +65,31 @@ const compileCommand: yargs.CommandModule = { ) .example( 'compile', - 'Compiles all workflows in the current project and writes JS files to tests/' + 'Compiles all workflows in the current project and writes JS files to compiled/' ) .example( - 'compile foo/job.js --test', - 'Strips adaptor operation calls and writes to tests/ (for unit testing)' + 'compile my-workflow', + 'Compiles a single workflow by name and writes JS files to compiled/' ) .example( - 'compile --test --no-strip', - 'Compiles entire project to tests/ without removing any code' + 'compile my-workflow -O', + 'Compiles a workflow and prints to stdout' + ) + .example( + 'compile foo/job.js --exports-only', + 'Strips adaptor operation calls, keeping only exported declarations' + ) + .example( + 'compile --exports-only', + 'Compiles entire project to compiled/ stripping operation calls' ) - .example( 'compile foo/job.js --watch', 'Watches the file and recompiles on every change' ) .example( - 'compile --test --watch', - 'Compiles all workflows in strip mode and recompiles on change' + 'compile --watch', + 'Compiles all workflows on change' ), }; diff --git a/packages/cli/src/compile/compile.ts b/packages/cli/src/compile/compile.ts index 48e11caf0..4f9878aa3 100644 --- a/packages/cli/src/compile/compile.ts +++ b/packages/cli/src/compile/compile.ts @@ -15,6 +15,9 @@ import type { CompileOptions } from './command'; export type CompiledJob = { code: string; map?: SourceMapWithOperations }; +export const hasExportableCode = (code: string): boolean => + /^\s*(export\s+(const|let|var|function|class)|const|let|var|function|class)\s/m.test(code); + export default async function ( job: ExecutionPlan, opts: CompileOptions, @@ -144,9 +147,11 @@ export const loadTransformOptions = async ( trace: opts.trace, }; - if (opts.test && opts.strip !== false) { - // Strip top-level operation calls instead of moving them to the export array - options['top-level-operations'] = { strip: true } as any; + if (opts.exportsOnly) { + options['exports-only'] = true; + // Disable transformers that produce output not needed for unit testing + options['ensure-exports'] = false; + options['top-level-operations'] = false; } // If an adaptor is passed in, we need to look up its declared exports // and pass them along to the compiler @@ -189,11 +194,13 @@ export const loadTransformOptions = async ( }; // Compile all steps across all workflows in the current project. -// Writes one .js file per step to compiledDir//.js +// Writes one .js file per step to compiledDir//.js. +// Pass workflowFilter to compile a single workflow by id or name. export const compileProject = async ( opts: CompileOptions, log: Logger, - cwd = process.cwd() + cwd = process.cwd(), + workflowFilter?: string ): Promise => { // validate=false suppresses warnings when workspace config has no extra metadata const workspace = new Workspace(cwd, log as any, false); @@ -207,17 +214,30 @@ export const compileProject = async ( } const wsConfig = workspace.getConfig() as any; - const compiledDir = path.resolve( - cwd, - opts.outputPath ?? (opts.test ? wsConfig.dirs?.tests ?? 'tests' : wsConfig.dirs?.compiled ?? 'compiled') - ); - log.info(`Compiling project to ${compiledDir}`); + const compiledDir = opts.outputStdout + ? null + : path.resolve(cwd, opts.outputPath ?? wsConfig.dirs?.compiled ?? 'compiled'); + + if (compiledDir) { + log.info(`Compiling project to ${compiledDir}`); + } + + let workflows = project.workflows; + if (workflowFilter) { + workflows = workflows.filter( + (wf: any) => wf.id === workflowFilter || wf.name === workflowFilter + ); + if (workflows.length === 0) { + log.error(`Workflow '${workflowFilter}' not found in project.`); + process.exit(1); + } + } const outPaths: string[] = []; const stalePaths: string[] = []; - for (const workflow of project.workflows) { + for (const workflow of workflows) { for (const step of workflow.steps) { const expression = (step as any).expression; if (!expression || typeof expression !== 'string') continue; @@ -236,19 +256,24 @@ export const compileProject = async ( (step as any).name ?? step.id ); - const outPath = path.join(compiledDir, workflow.id, `${step.id}.js`); - - if (opts.test && opts.strip !== false && !/^\s*(export\s+(const|let|var|function|class)|const|let|var|function|class)\s/m.test(code)) { + if (opts.exportsOnly && !hasExportableCode(code)) { + const stalePath = compiledDir + ? path.join(compiledDir, workflow.id, `${step.id}.js`) + : null; log.info(` ${workflow.id}/${step.id} — skipped (no exportable code after stripping)`); - stalePaths.push(outPath); + if (stalePath) stalePaths.push(stalePath); continue; } - await fs.mkdir(path.dirname(outPath), { recursive: true }); - await fs.writeFile(outPath, code); - - outPaths.push(outPath); - log.success(` ${workflow.id}/${step.id} → ${outPath}`); + if (opts.outputStdout) { + log.success(`// ${workflow.id}/${step.id}\n\n` + code); + } else { + const outPath = path.join(compiledDir!, workflow.id, `${step.id}.js`); + await fs.mkdir(path.dirname(outPath), { recursive: true }); + await fs.writeFile(outPath, code); + outPaths.push(outPath); + log.success(` ${workflow.id}/${step.id} → ${outPath}`); + } } } @@ -263,6 +288,8 @@ export const compileProject = async ( } } - log.success(`Compiled ${outPaths.length} step(s) to ${compiledDir}`); + if (!opts.outputStdout) { + log.success(`Compiled ${outPaths.length} step(s) to ${compiledDir}`); + } return outPaths; }; diff --git a/packages/cli/src/compile/handler.ts b/packages/cli/src/compile/handler.ts index d31fca0ef..1db29d7ee 100644 --- a/packages/cli/src/compile/handler.ts +++ b/packages/cli/src/compile/handler.ts @@ -1,7 +1,6 @@ import path from 'node:path'; import { writeFile, mkdir } from 'node:fs/promises'; import chokidar from 'chokidar'; -import { Workspace } from '@openfn/project'; import type { CompileOptions } from './command'; import type { Logger } from '../util/logger'; @@ -9,24 +8,6 @@ import compile, { compileProject } from './compile'; import loadPlan from '../util/load-plan'; import assertPath from '../util/assert-path'; -// Returns false when compiled strip output has no declarations worth importing in tests. -export const hasExportableCode = (code: string): boolean => - /^\s*(export\s+(const|let|var|function|class)|const|let|var|function|class)\s/m.test(code); - -export const deriveTestOutputPath = ( - expressionPath: string, - compiledDir: string, - workflowsDir: string, - cwd = process.cwd() -): string => { - const absInput = path.resolve(cwd, expressionPath); - const absWorkflows = path.resolve(cwd, workflowsDir); - if (absInput.startsWith(absWorkflows + path.sep)) { - return path.resolve(cwd, compiledDir, path.relative(absWorkflows, absInput)); - } - return path.resolve(cwd, compiledDir, path.basename(expressionPath)); -}; - // Derive the watch target(s) from the compile options. // Returns either file paths or directory globs that chokidar can watch. const collectWatchTargets = (options: CompileOptions): string[] => { @@ -41,13 +22,9 @@ const collectWatchTargets = (options: CompileOptions): string[] => { return [path.join(process.cwd(), 'workflows', '**', '*.js')]; }; -// Run a single compilation pass and write output based on options. +// Compile a single file (.js expression or .json/.yaml workflow) and print or save result. +// Defaults to stdout unless -o is given. const doCompile = async (options: CompileOptions, logger: Logger) => { - if (!options.path) { - await compileProject(options, logger); - return; - } - let result: string; if (options.expressionPath) { const { code } = await compile(options.expressionPath, options, logger); @@ -58,38 +35,24 @@ const doCompile = async (options: CompileOptions, logger: Logger) => { result = JSON.stringify(compiledPlan, null, 2); } - let outputPath = options.outputPath; - let outputStdout = options.outputStdout; - - if (options.test && outputStdout && options.expressionPath) { - const cwd = process.cwd(); - const workspace = new Workspace(cwd, logger as any, false); - const wsConfig = workspace.getConfig() as any; - const compiledDir = wsConfig?.dirs?.tests ?? 'tests'; - const workflowsDir = wsConfig?.dirs?.workflows ?? 'workflows'; - outputPath = deriveTestOutputPath(options.expressionPath, compiledDir, workflowsDir, cwd); - outputStdout = false; - } - - if (options.test && options.strip !== false && !hasExportableCode(result)) { - logger.info(`Skipped ${outputPath ?? options.expressionPath} — no exportable code after stripping`); - return; - } - - if (outputStdout) { - logger.success('Result:\n\n' + result); + if (options.outputPath) { + await mkdir(path.dirname(options.outputPath), { recursive: true }); + await writeFile(options.outputPath, result); + logger.success(`Compiled to ${options.outputPath}`); } else { - await mkdir(path.dirname(outputPath!), { recursive: true }); - await writeFile(outputPath!, result); - logger.success(`Compiled to ${outputPath}`); + logger.success('Result:\n\n' + result); } }; const compileHandler = async (options: CompileOptions, logger: Logger) => { - if (!options.path) { - // Project mode: no path given, compile all workflows in the current project + if (options.workflowName) { + // Workflow name: look it up in the project and compile to disk (or stdout with -O) + await compileProject(options, logger, process.cwd(), options.workflowName); + } else if (!options.path) { + // No path: compile the whole project to disk (or stdout with -O) await compileProject(options, logger); } else { + // File path (.js / .json / .yaml): compile and print to stdout (or -o file) assertPath(options.path); await doCompile(options, logger); } @@ -101,13 +64,19 @@ const compileHandler = async (options: CompileOptions, logger: Logger) => { const watcher = chokidar.watch(watchTargets, { ignoreInitial: true, - ignored: ['**/node_modules/**', '**/tests/**', '**/compiled/**'], + ignored: ['**/node_modules/**', '**/compiled/**'], }); - watcher.on('change', async (changedPath) => { + watcher.on('change', async (changedPath: string) => { logger.info(`${changedPath} changed, recompiling...`); try { - await doCompile(options, logger); + if (options.workflowName) { + await compileProject(options, logger, process.cwd(), options.workflowName); + } else if (!options.path) { + await compileProject(options, logger); + } else { + await doCompile(options, logger); + } } catch (e) { logger.error('Compilation error:', e); } diff --git a/packages/cli/src/options.ts b/packages/cli/src/options.ts index 247b09199..d56660ec8 100644 --- a/packages/cli/src/options.ts +++ b/packages/cli/src/options.ts @@ -66,8 +66,7 @@ export type Opts = { statePath?: string; stateStdin?: string; timeout?: number; // ms - test?: boolean; - strip?: boolean; + exportsOnly?: boolean; trace?: boolean; useAdaptorsMonorepo?: boolean; watch?: boolean; @@ -626,27 +625,16 @@ export const validate: CLIOption = { }, }; -export const testFlag: CLIOption = { - name: 'test', +export const exportsOnly: CLIOption = { + name: 'exports-only', yargs: { boolean: true, description: - 'Compile for unit testing: strips adaptor operation calls and writes output to tests/', + 'Strip adaptor operation calls, exporting only constants and functions', default: false, }, }; -export const stripFlag: CLIOption = { - name: 'strip', - yargs: { - boolean: true, - description: - 'Used with --test: strip operation calls from compiled output. Use --no-strip to keep them.', - default: true, - }, -}; - - export const watchFlag: CLIOption = { name: 'watch', yargs: { diff --git a/packages/cli/test/compile/compile.test.ts b/packages/cli/test/compile/compile.test.ts index e81a46da5..06c5eabce 100644 --- a/packages/cli/test/compile/compile.test.ts +++ b/packages/cli/test/compile/compile.test.ts @@ -8,6 +8,7 @@ import compile, { loadTransformOptions, resolveSpecifierPath, compileProject, + hasExportableCode, } from '../../src/compile/compile'; import { CompileOptions } from '../../src/compile/command'; import { mockFs, resetMockFs } from '../util'; @@ -346,28 +347,44 @@ test.serial('loadTransformOptions: ignore some imports', async (t) => { // TODO test exception if the module can't be found -test.serial('loadTransformOptions: test flag sets strip mode', async (t) => { - const opts = { test: true } as CompileOptions; +test.serial('loadTransformOptions: --exports-only enables exports-only transformer', async (t) => { + const opts = { exportsOnly: true } as CompileOptions; const result = await loadTransformOptions(opts, mockLog); - t.deepEqual(result['top-level-operations'], { strip: true }); + t.is(result['exports-only'], true); + t.is(result['ensure-exports'], false); + t.is(result['top-level-operations'], false); }); -test.serial('loadTransformOptions: --no-strip disables strip mode', async (t) => { - const opts = { test: true, strip: false } as CompileOptions; - const result = await loadTransformOptions(opts, mockLog); - t.falsy(result['top-level-operations']); -}); - - test.serial( - 'loadTransformOptions: test flag does not affect other options', + 'loadTransformOptions: --exports-only does not affect other options', async (t) => { - const opts = { test: true } as CompileOptions; + const opts = { exportsOnly: true } as CompileOptions; const result = await loadTransformOptions(opts, mockLog); t.falsy(result['add-imports']); } ); +// hasExportableCode +test('hasExportableCode: returns true for const declaration', (t) => { + t.true(hasExportableCode("import x from 'y';\nconst helper = () => {};\nexport default [];")); +}); + +test('hasExportableCode: returns true for function declaration', (t) => { + t.true(hasExportableCode("function formatDate(d) { return d; }\nexport default [];")); +}); + +test('hasExportableCode: returns true for exported const', (t) => { + t.true(hasExportableCode("export const VALUE = 42;\nexport default [];")); +}); + +test('hasExportableCode: returns false when only imports', (t) => { + t.false(hasExportableCode("import { get } from '@openfn/language-http';")); +}); + +test('hasExportableCode: returns false for empty string', (t) => { + t.false(hasExportableCode('')); +}); + test.serial( 'compileProject: compiles all workflow steps and writes output files', async (t) => { @@ -408,13 +425,13 @@ steps: ); test.serial( - 'compileProject: removes stale step files skipped in current run', + 'compileProject: removes stale step files skipped in --exports-only run', async (t) => { const pnpm = path.resolve('../../node_modules/.pnpm'); const recastPath = `${pnpm}/recast@0.21.5`; const sourceMapPath = `${pnpm}/source-map@0.7.6`; - // step-b has no exported code and will be skipped in strip mode. + // step-b has no exported code and will be skipped in --exports-only mode. // Pre-populate a stale file at its expected output path. mock({ [recastPath]: mock.load(recastPath, {}), @@ -431,14 +448,14 @@ steps: - id: step-b expression: "fn();" `, - // stale file from a previous --no-strip run - '/proj/tests/wf1/step-b.js': 'export default [fn()];', - // user-added test file — must not be touched - '/proj/tests/wf1/step-b.test.js': 'import { } from "./step-b.js";', + // stale file from a previous run without --exports-only + '/proj/compiled/wf1/step-b.js': 'export default [fn()];', + // user-added file — must not be touched + '/proj/compiled/wf1/step-b.test.js': 'import { } from "./step-b.js";', }); await compileProject( - { test: true } as CompileOptions, + { exportsOnly: true } as CompileOptions, mockLog, '/proj' ); @@ -446,12 +463,12 @@ steps: const { default: nodeFsPromises } = await import('node:fs/promises'); // Stale step file should be gone - await t.throwsAsync(() => nodeFsPromises.readFile('/proj/tests/wf1/step-b.js'), { + await t.throwsAsync(() => nodeFsPromises.readFile('/proj/compiled/wf1/step-b.js'), { code: 'ENOENT', }); - // User test file must still exist - const userFile = await nodeFsPromises.readFile('/proj/tests/wf1/step-b.test.js', 'utf-8'); + // User file must still exist + const userFile = await nodeFsPromises.readFile('/proj/compiled/wf1/step-b.test.js', 'utf-8'); t.truthy(userFile); mock.restore(); diff --git a/packages/cli/test/compile/handler.test.ts b/packages/cli/test/compile/handler.test.ts index 9bf4d5f61..f329a4e02 100644 --- a/packages/cli/test/compile/handler.test.ts +++ b/packages/cli/test/compile/handler.test.ts @@ -1,77 +1,7 @@ import test from 'ava'; -import path from 'node:path'; -import { deriveTestOutputPath, hasExportableCode } from '../../src/compile/handler'; -const cwd = '/project'; - -// deriveTestOutputPath -test('path inside workflows dir → compiled/', (t) => { - const result = deriveTestOutputPath( - 'workflows/dhis2-sync/transform.js', - 'compiled', - 'workflows', - cwd - ); - t.is(result, path.resolve(cwd, 'compiled/dhis2-sync/transform.js')); -}); - -test('path outside workflows dir → compiled/', (t) => { - const result = deriveTestOutputPath( - 'scripts/helper.js', - 'compiled', - 'workflows', - cwd - ); - t.is(result, path.resolve(cwd, 'compiled/helper.js')); -}); - -test('respects custom compiledDir', (t) => { - const result = deriveTestOutputPath( - 'workflows/step.js', - 'dist/compiled', - 'workflows', - cwd - ); - t.is(result, path.resolve(cwd, 'dist/compiled/step.js')); -}); - -test('respects custom workflowsDir', (t) => { - const result = deriveTestOutputPath( - 'jobs/my-workflow/step.js', - 'compiled', - 'jobs', - cwd - ); - t.is(result, path.resolve(cwd, 'compiled/my-workflow/step.js')); -}); - -test('preserves nested subdirectory structure', (t) => { - const result = deriveTestOutputPath( - 'workflows/wf-a/subdir/step.js', - 'compiled', - 'workflows', - cwd - ); - t.is(result, path.resolve(cwd, 'compiled/wf-a/subdir/step.js')); -}); - -// hasExportableCode -test('hasExportableCode: returns true for const declaration', (t) => { - t.true(hasExportableCode("import x from 'y';\nconst helper = () => {};\nexport default [];")); -}); - -test('hasExportableCode: returns true for function declaration', (t) => { - t.true(hasExportableCode("function formatDate(d) { return d; }\nexport default [];")); -}); - -test('hasExportableCode: returns true for exported const', (t) => { - t.true(hasExportableCode("export const VALUE = 42;\nexport default [];")); -}); - -test('hasExportableCode: returns false when only imports', (t) => { - t.false(hasExportableCode("import { get } from '@openfn/language-http';")); -}); - -test('hasExportableCode: returns false for empty string', (t) => { - t.false(hasExportableCode('')); +// hasExportableCode and compileProject tests live in compile.test.ts. +// This file is retained as a placeholder. +test('placeholder', (t) => { + t.pass(); }); diff --git a/packages/cli/test/compile/options.test.ts b/packages/cli/test/compile/options.test.ts index a43b76b6e..720941a51 100644 --- a/packages/cli/test/compile/options.test.ts +++ b/packages/cli/test/compile/options.test.ts @@ -15,7 +15,7 @@ test('correct default options', (t) => { t.is(options.expandAdaptors, true); t.is(options.expressionPath, 'job.js'); t.falsy(options.logJson); // TODO this is undefined right now - t.is(options.outputStdout, true); + t.is(options.outputStdout, false); t.is(options.path, 'job.js'); t.falsy(options.useAdaptorsMonorepo); }); diff --git a/packages/compiler/src/transform.ts b/packages/compiler/src/transform.ts index 0e3245960..bac5e4f69 100644 --- a/packages/compiler/src/transform.ts +++ b/packages/compiler/src/transform.ts @@ -5,6 +5,7 @@ import createLogger, { Logger } from '@openfn/logger'; import addImports, { AddImportsOptions } from './transforms/add-imports'; import ensureExports from './transforms/ensure-exports'; +import exportsOnly from './transforms/exports-only'; import lazyState from './transforms/lazy-state'; import promises from './transforms/promises'; import topLevelOps, { @@ -15,6 +16,7 @@ import { heap } from './util'; export type TransformerName = | 'add-imports' | 'ensure-exports' + | 'exports-only' | 'top-level-operations' | 'test' | 'lazy-state'; @@ -38,6 +40,7 @@ export type TransformOptions = { ['add-imports']?: AddImportsOptions | boolean; ['ensure-exports']?: boolean; + ['exports-only']?: boolean; ['top-level-operations']?: TopLevelOpsOptions | boolean; ['test']?: any; ['lazy-state']?: any; @@ -60,6 +63,7 @@ export default function transform( if (!transformers) { transformers = [ + exportsOnly, lazyState, promises, ensureExports, diff --git a/packages/compiler/src/transforms/exports-only.ts b/packages/compiler/src/transforms/exports-only.ts new file mode 100644 index 000000000..92dbf510c --- /dev/null +++ b/packages/compiler/src/transforms/exports-only.ts @@ -0,0 +1,118 @@ +/* + * Strip all non-exported top-level code. + * + * Keeps: import declarations and named export declarations. + * Drops: expression statements (operations), export default, and bare + * top-level declarations that are not referenced by any kept export. + * + * Non-exported declarations that are transitively referenced by a kept + * export declaration are preserved (tree-shaking). + * + * This transformer is designed to run before all others (order: 0). + * It is a no-op unless explicitly enabled via options['exports-only'] = true. + */ + +import { namedTypes as n } from 'ast-types'; +import type { NodePath } from 'ast-types/lib/node-path'; +import type { Transformer } from '../transform'; + +// Recursively collect all Identifier names referenced in a node. +// Conservative: includes property names in member expressions (avoids false negatives). +export const collectRefs = (node: any, refs = new Set()): Set => { + if (!node || typeof node !== 'object') return refs; + if (Array.isArray(node)) { + node.forEach(item => collectRefs(item, refs)); + return refs; + } + if (n.Identifier.check(node)) refs.add(node.name); + for (const key of Object.keys(node)) { + if (key === 'type' || key === 'loc' || key === 'comments' || key === 'tokens') continue; + collectRefs(node[key], refs); + } + return refs; +}; + +// Build a map from declared name → statement for non-export top-level declarations. +export const buildDeclMap = ( + nodes: n.Statement[] +): Map => { + const map = new Map(); + for (const node of nodes) { + if (n.VariableDeclaration.check(node)) { + for (const d of node.declarations) { + if (n.Identifier.check((d as n.VariableDeclarator).id)) { + map.set(((d as n.VariableDeclarator).id as n.Identifier).name, node); + } + } + } else if (n.FunctionDeclaration.check(node) && node.id) { + map.set(node.id.name, node); + } + } + return map; +}; + +// Transitively collect all declarations that the seed nodes depend on. +export const collectDeps = ( + seeds: n.Statement[], + declMap: Map +): Set => { + const result = new Set(seeds); + const queue = [...seeds]; + while (queue.length) { + const current = queue.shift()!; + for (const ref of Array.from(collectRefs(current))) { + const dep = declMap.get(ref); + if (dep && !result.has(dep)) { + result.add(dep); + queue.push(dep); + } + } + } + return result; +}; + +function visitor( + programPath: NodePath, + _logger: any, + options: boolean | {} = {} +) { + // Only run when explicitly enabled + if (options !== true) return; + + const body = programPath.node.body; + + // Bare (non-export, non-import) top-level statements — candidates for tree-shaking + const nonExported = body.filter( + node => + !n.ImportDeclaration.check(node) && + !n.ExportNamedDeclaration.check(node) && + !n.ExportDefaultDeclaration.check(node) + ) as n.Statement[]; + + const declMap = buildDeclMap(nonExported); + + // Seed tree-shaking from all named export declarations + const exportSeeds = body.filter(node => + n.ExportNamedDeclaration.check(node) + ) as n.Statement[]; + + // Transitively collect non-exported declarations that exports depend on + const needed = collectDeps(exportSeeds, declMap); + + // Keep imports, named exports, and their transitive non-exported dependencies + programPath.node.body = body.filter( + node => + n.ImportDeclaration.check(node) || + n.ExportNamedDeclaration.check(node) || + needed.has(node as n.Statement) + ) as any; + + return true; // abort further traversal +} + +export default { + id: 'exports-only', + types: ['Program'], + order: 0, + visitor, +} as unknown as Transformer; diff --git a/packages/compiler/src/transforms/top-level-operations.ts b/packages/compiler/src/transforms/top-level-operations.ts index 7f9d6f2fa..edbaf799e 100644 --- a/packages/compiler/src/transforms/top-level-operations.ts +++ b/packages/compiler/src/transforms/top-level-operations.ts @@ -17,69 +17,9 @@ export type ExtendedProgram = NodePath< export type TopLevelOpsOptions = { // Wrap operations in a `(state) => op` wrapper wrap?: boolean; // TODO - // Strip operations instead of moving them into the export array (for test compilation) - strip?: boolean; }; -// Recursively collect all Identifier names referenced in a node. -// Conservative: includes property names in member expressions (avoids false negatives). -export const collectRefs = (node: any, refs = new Set()): Set => { - if (!node || typeof node !== 'object') return refs; - if (Array.isArray(node)) { - node.forEach(item => collectRefs(item, refs)); - return refs; - } - if (n.Identifier.check(node)) refs.add(node.name); - for (const key of Object.keys(node)) { - if (key === 'type' || key === 'loc' || key === 'comments' || key === 'tokens') continue; - collectRefs(node[key], refs); - } - return refs; -}; - -// Build a map from declared name → statement for non-export top-level declarations. -export const buildDeclMap = ( - nodes: namedTypes.Statement[] -): Map => { - const map = new Map(); - for (const node of nodes) { - if (n.VariableDeclaration.check(node)) { - for (const d of node.declarations) { - if (n.Identifier.check((d as namedTypes.VariableDeclarator).id)) { - map.set( - ((d as namedTypes.VariableDeclarator).id as namedTypes.Identifier).name, - node - ); - } - } - } else if (n.FunctionDeclaration.check(node) && node.id) { - map.set(node.id.name, node); - } - } - return map; -}; - -// Transitively collect all declarations that the seed nodes depend on. -export const collectDeps = ( - seeds: namedTypes.Statement[], - declMap: Map -): Set => { - const result = new Set(seeds); - const queue = [...seeds]; - while (queue.length) { - const current = queue.shift()!; - for (const ref of collectRefs(current)) { - const dep = declMap.get(ref); - if (dep && !result.has(dep)) { - result.add(dep); - queue.push(dep); - } - } - } - return result; -}; - -function visitor(programPath: ExtendedProgram, _logger: any, options: Partial = {}) { +function visitor(programPath: ExtendedProgram, _logger: any, _options: Partial = {}) { const operations: Array<{ line: number; name: string; order: number }> = []; const children = programPath.node.body; const rem = []; @@ -99,55 +39,10 @@ function visitor(programPath: ExtendedProgram, _logger: any, options: Partial { - if (!n.ImportDeclaration.check(node)) return true; - if (node.source.value !== '@openfn/runtime') return true; - const filtered = (node.specifiers ?? []).filter(s => { - if (!n.ImportSpecifier.check(s)) return true; - return (s.local as namedTypes.Identifier).name !== '_defer'; - }); - if (filtered.length === 0) return false; - node.specifiers = filtered; - return true; - }); - - // Tree-shake: keep only explicitly exported declarations and their dependencies. - // Non-exported top-level declarations with no exported consumer are dropped. - const body = programPath.node.body; - - const exportedDecls = body.filter( - node => - n.ExportNamedDeclaration.check(node) && - (node as namedTypes.ExportNamedDeclaration).declaration != null - ) as namedTypes.Statement[]; - - const nonExported = body.filter( - node => - !n.ImportDeclaration.check(node) && - !n.ExportDefaultDeclaration.check(node) && - !n.ExportNamedDeclaration.check(node) - ); - const declMap = buildDeclMap(nonExported); - - const needed = collectDeps(exportedDecls, declMap); - - programPath.node.body = body.filter( - node => - n.ImportDeclaration.check(node) || - needed.has(node as namedTypes.Statement) - ); - } } else { // error! there isn't an appropriate export statement // What do we do? diff --git a/packages/compiler/test/compile.test.ts b/packages/compiler/test/compile.test.ts index 47702abf4..0f44148f5 100644 --- a/packages/compiler/test/compile.test.ts +++ b/packages/compiler/test/compile.test.ts @@ -279,16 +279,20 @@ test('respect ignore list when exports not provided', (t) => { t.is(result, expected); }); -test('strip mode: removes top-level operations, keeps exported JS', (t) => { +const exportsOnlyOpts = { + 'exports-only': true, + 'ensure-exports': false, + 'top-level-operations': false, +} as const; + +test('exports-only: removes top-level operations, keeps exported JS', (t) => { const source = [ 'export const formatDate = (d) => d.toISOString();', 'get("/api");', 'fn(state => ({ ...state, date: formatDate(state.data.date) }));', ].join('\n'); - const { code: result } = compile(source, { - 'top-level-operations': { strip: true }, - }); + const { code: result } = compile(source, exportsOnlyOpts); t.true(result.includes('export const formatDate')); t.false(result.includes('get(')); @@ -296,23 +300,21 @@ test('strip mode: removes top-level operations, keeps exported JS', (t) => { t.false(result.includes('export default []')); }); -test('strip mode: removes non-exported declarations', (t) => { +test('exports-only: removes non-exported declarations', (t) => { const source = [ 'const formatDate = (d) => d.toISOString();', 'get("/api");', ].join('\n'); - const { code: result } = compile(source, { - 'top-level-operations': { strip: true }, - }); + const { code: result } = compile(source, exportsOnlyOpts); - // non-exported const is dropped by tree-shaking; export default [] is also gone + // non-exported const is dropped; no export default [] t.false(result.includes('const formatDate')); t.false(result.includes('get(')); t.false(result.includes('export default []')); }); -test('strip mode: keeps import statements', (t) => { +test('exports-only: keeps import statements', (t) => { const source = [ 'import { dateFns } from "@openfn/language-common";', 'export const formatDate = (d) => dateFns.format(d);', @@ -320,9 +322,7 @@ test('strip mode: keeps import statements', (t) => { 'export default [];', ].join('\n'); - const { code: result } = compile(source, { - 'top-level-operations': { strip: true }, - }); + const { code: result } = compile(source, exportsOnlyOpts); t.true(result.includes('import { dateFns }')); t.true(result.includes('export const formatDate')); diff --git a/packages/compiler/test/transforms/exports-only.test.ts b/packages/compiler/test/transforms/exports-only.test.ts new file mode 100644 index 000000000..980ff426a --- /dev/null +++ b/packages/compiler/test/transforms/exports-only.test.ts @@ -0,0 +1,241 @@ +import test from 'ava'; +import { builders as b, namedTypes as n } from 'ast-types'; +import { print } from 'recast'; +import transform from '../../src/transform'; +import visitors, { + collectRefs, + buildDeclMap, + collectDeps, +} from '../../src/transforms/exports-only'; + +// Helpers +const makeConst = (name: string, value: any = b.literal(42)) => + b.variableDeclaration('const', [b.variableDeclarator(b.identifier(name), value)]); + +const makeExportConst = (name: string, value: any = b.literal(42)) => + b.exportNamedDeclaration(makeConst(name, value), []); + +const makeExportFn = (name: string, body: any[] = [b.returnStatement(b.literal(1))]) => + b.exportNamedDeclaration( + b.functionDeclaration(b.identifier(name), [], b.blockStatement(body)), + [] + ); + +const makeOp = (name: string) => + b.expressionStatement(b.callExpression(b.identifier(name), [])); + +const makeImport = (specifier: string, source: string) => + b.importDeclaration( + [b.importSpecifier(b.identifier(specifier))], + b.stringLiteral(source) + ); + +// --- collectRefs --- + +test('collectRefs: finds identifiers in a simple expression', (t) => { + const node = b.expressionStatement( + b.callExpression(b.identifier('fn'), [b.identifier('x')]) + ); + const refs = collectRefs(node); + t.true(refs.has('fn')); + t.true(refs.has('x')); +}); + +test('collectRefs: traverses nested nodes', (t) => { + const node = b.arrowFunctionExpression( + [], + b.callExpression(b.identifier('helper'), []) + ); + const refs = collectRefs(node); + t.true(refs.has('helper')); +}); + +// --- buildDeclMap --- + +test('buildDeclMap: maps variable declarations', (t) => { + const decl = makeConst('myVar'); + const map = buildDeclMap([decl]); + t.true(map.has('myVar')); + t.is(map.get('myVar'), decl); +}); + +test('buildDeclMap: maps function declarations', (t) => { + const decl = b.functionDeclaration(b.identifier('myFn'), [], b.blockStatement([])); + const map = buildDeclMap([decl]); + t.true(map.has('myFn')); +}); + +test('buildDeclMap: ignores export declarations', (t) => { + const decl = makeExportConst('exported'); + const map = buildDeclMap([decl]); + t.false(map.has('exported')); +}); + +// --- collectDeps --- + +test('collectDeps: includes seeds in result', (t) => { + const seed = makeConst('x'); + const result = collectDeps([seed], new Map()); + t.true(result.has(seed)); +}); + +test('collectDeps: transitively follows dependencies', (t) => { + const dep = makeConst('helper'); + const declMap = new Map([['helper', dep]]); + const seed = b.exportNamedDeclaration( + b.functionDeclaration( + b.identifier('doThing'), + [], + b.blockStatement([ + b.returnStatement(b.callExpression(b.identifier('helper'), [])), + ]) + ), + [] + ); + const result = collectDeps([seed], declMap); + t.true(result.has(dep)); +}); + +// --- exports-only transformer --- + +test('is a no-op when options is not true', (t) => { + const ast = b.program([makeOp('fn'), b.exportDefaultDeclaration(b.arrayExpression([]))]); + const before = print(ast).code; + const after = print(transform(ast, [visitors])).code; + t.is(before, after); +}); + +test('is a no-op when options is false', (t) => { + const ast = b.program([makeOp('fn')]); + const before = print(ast).code; + const after = print(transform(ast, [visitors], { 'exports-only': false })).code; + t.is(before, after); +}); + +test('strips operation calls', (t) => { + const ast = b.program([makeOp('get'), makeOp('fn')]); + const { body } = transform(ast, [visitors], { 'exports-only': true }) as n.Program; + t.is(body.length, 0); +}); + +test('strips export default []', (t) => { + const ast = b.program([ + makeOp('fn'), + b.exportDefaultDeclaration(b.arrayExpression([])), + ]); + const { body } = transform(ast, [visitors], { 'exports-only': true }) as n.Program; + t.is(body.length, 0); +}); + +test('strips non-exported declarations', (t) => { + const ast = b.program([makeConst('x'), makeOp('fn')]); + const { body } = transform(ast, [visitors], { 'exports-only': true }) as n.Program; + t.is(body.length, 0); +}); + +test('keeps import declarations', (t) => { + const imp = makeImport('get', '@openfn/language-http'); + const ast = b.program([imp, makeOp('fn')]); + const { body } = transform(ast, [visitors], { 'exports-only': true }) as n.Program; + t.is(body.length, 1); + t.true(n.ImportDeclaration.check(body[0])); +}); + +test('keeps named export declarations', (t) => { + const exported = makeExportConst('helper'); + const ast = b.program([exported, makeOp('fn')]); + const { body } = transform(ast, [visitors], { 'exports-only': true }) as n.Program; + t.is(body.length, 1); + t.true(n.ExportNamedDeclaration.check(body[0])); +}); + +test('keeps exported function declarations', (t) => { + const ast = b.program([makeExportFn('formatDate'), makeOp('fn')]); + const { body } = transform(ast, [visitors], { 'exports-only': true }) as n.Program; + t.is(body.length, 1); + t.true(n.ExportNamedDeclaration.check(body[0])); +}); + +test('keeps non-exported declarations that an export depends on', (t) => { + const helper = makeConst( + 'fmt', + b.arrowFunctionExpression([b.identifier('d')], b.identifier('d')) + ); + const exported = makeExportFn('formatDate', [ + b.returnStatement(b.callExpression(b.identifier('fmt'), [b.identifier('date')])), + ]); + const ast = b.program([helper, exported, makeOp('fn')]); + const { body } = transform(ast, [visitors], { 'exports-only': true }) as n.Program; + t.is(body.length, 2); + t.true(n.VariableDeclaration.check(body[0])); + t.true(n.ExportNamedDeclaration.check(body[1])); +}); + +test('drops non-exported declarations that no export depends on', (t) => { + const unused = makeConst('unused'); + const exported = makeExportFn('greet', [b.returnStatement(b.stringLiteral('hi'))]); + const ast = b.program([unused, exported, makeOp('fn')]); + const { body } = transform(ast, [visitors], { 'exports-only': true }) as n.Program; + t.is(body.length, 1); + t.true(n.ExportNamedDeclaration.check(body[0])); +}); + +test('follows transitive dependencies', (t) => { + // exported → middle → leaf + const leaf = makeConst( + 'leaf', + b.arrowFunctionExpression([], b.stringLiteral('leaf')) + ); + const middle = makeConst( + 'middle', + b.callExpression(b.identifier('leaf'), []) + ); + const exported = makeExportFn('top', [ + b.returnStatement(b.callExpression(b.identifier('middle'), [])), + ]); + const ast = b.program([leaf, middle, exported, makeOp('fn')]); + const { body } = transform(ast, [visitors], { 'exports-only': true }) as n.Program; + // leaf + middle + exported — no op, no export default + t.is(body.length, 3); +}); + +test('keeps imports alongside named exports', (t) => { + const imp = makeImport('dateFns', '@openfn/language-dhis2'); + const exported = makeExportConst('formatDate'); + const ast = b.program([imp, exported, makeOp('fn')]); + const { body } = transform(ast, [visitors], { 'exports-only': true }) as n.Program; + t.is(body.length, 2); + t.true(n.ImportDeclaration.check(body[0])); + t.true(n.ExportNamedDeclaration.check(body[1])); +}); + +test('handles a file with only operations (no exports)', (t) => { + const ast = b.program([makeOp('fn'), makeOp('get')]); + const { body } = transform(ast, [visitors], { 'exports-only': true }) as n.Program; + t.is(body.length, 0); +}); + +test('handles an empty file', (t) => { + const ast = b.program([]); + const { body } = transform(ast, [visitors], { 'exports-only': true }) as n.Program; + t.is(body.length, 0); +}); + +test('handles multiple exports without operations', (t) => { + const ast = b.program([ + makeExportConst('a'), + makeExportConst('b'), + makeExportFn('c'), + ]); + const { body } = transform(ast, [visitors], { 'exports-only': true }) as n.Program; + t.is(body.length, 3); +}); + +test('does not remove export default when exports-only is disabled', (t) => { + const ast = b.program([ + makeOp('fn'), + b.exportDefaultDeclaration(b.arrayExpression([])), + ]); + const { body } = transform(ast, [visitors], { 'exports-only': false }) as n.Program; + t.is(body.length, 2); +}); diff --git a/packages/compiler/test/transforms/top-level-operations.test.ts b/packages/compiler/test/transforms/top-level-operations.test.ts index 47aba0584..f3a77020a 100644 --- a/packages/compiler/test/transforms/top-level-operations.test.ts +++ b/packages/compiler/test/transforms/top-level-operations.test.ts @@ -217,162 +217,6 @@ 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('strip: removes top-level operations instead of moving them to exports', (t) => { - const ast = createProgramWithExports([ - createOperationStatement('get'), - createOperationStatement('fn'), - ]); - - const { body } = transform(ast, [visitors], { - 'top-level-operations': { strip: true }, - }); - - // Nothing remains — operations and export default [] are both removed - t.is(body.length, 0); -}); - -test('strip: removes non-exported top-level declarations', (t) => { - const ast = createProgramWithExports([ - b.variableDeclaration('const', [ - b.variableDeclarator(b.identifier('x'), b.literal(42)), - ]), - createOperationStatement('get'), - ]); - - const { body } = transform(ast, [visitors], { - 'top-level-operations': { strip: true }, - }); - - // Nothing remains — non-exported const and export default [] are both removed - t.is(body.length, 0); -}); - -test('strip: keeps exported const declarations', (t) => { - const ast = createProgramWithExports([ - b.exportNamedDeclaration( - b.variableDeclaration('const', [ - b.variableDeclarator(b.identifier('helper'), b.literal(42)), - ]), - [] - ), - createOperationStatement('get'), - ]); - - const { body } = transform(ast, [visitors], { - 'top-level-operations': { strip: true }, - }); - - // Only export const helper — export default [] is removed - t.is(body.length, 1); - t.true(n.ExportNamedDeclaration.check(body[0])); -}); - -test('strip: keeps non-exported declarations that exported functions depend on', (t) => { - const localHelper = b.variableDeclaration('const', [ - b.variableDeclarator( - b.identifier('fmt'), - b.arrowFunctionExpression([b.identifier('d')], b.identifier('d')) - ), - ]); - const exportedFn = b.exportNamedDeclaration( - b.functionDeclaration( - b.identifier('formatDate'), - [b.identifier('date')], - b.blockStatement([ - b.returnStatement(b.callExpression(b.identifier('fmt'), [b.identifier('date')])), - ]) - ), - [] - ); - - const ast = createProgramWithExports([ - localHelper, - exportedFn, - createOperationStatement('get'), - ]); - - const { body } = transform(ast, [visitors], { - 'top-level-operations': { strip: true }, - }); - - // const fmt + export function formatDate — no export default [] - t.is(body.length, 2); - t.true(n.VariableDeclaration.check(body[0])); - t.true(n.ExportNamedDeclaration.check(body[1])); -}); - -test('strip: drops unreferenced non-exported declarations', (t) => { - const localUnused = b.variableDeclaration('const', [ - b.variableDeclarator(b.identifier('unused'), b.literal(99)), - ]); - const exportedFn = b.exportNamedDeclaration( - b.functionDeclaration( - b.identifier('greet'), - [], - b.blockStatement([b.returnStatement(b.stringLiteral('hi'))]) - ), - [] - ); - - const ast = createProgramWithExports([ - localUnused, - exportedFn, - createOperationStatement('fn'), - ]); - - const { body } = transform(ast, [visitors], { - 'top-level-operations': { strip: true }, - }); - - // Only export function greet — unused const and export default [] are gone - t.is(body.length, 1); - t.true(n.ExportNamedDeclaration.check(body[0])); -}); - -test('strip: removes injected _defer import from @openfn/runtime', (t) => { - const deferImport = b.importDeclaration( - [b.importSpecifier(b.identifier('defer'), b.identifier('_defer'))], - b.stringLiteral('@openfn/runtime') - ); - const ast = b.program([ - deferImport, - createOperationStatement('get'), - b.exportDefaultDeclaration(b.arrayExpression([])), - ]); - - const { body } = transform(ast, [visitors], { - 'top-level-operations': { strip: true }, - }) as n.Program; - - // Everything stripped — body is empty - t.is(body.length, 0); -}); - -test('strip: keeps other @openfn/runtime specifiers when removing _defer', (t) => { - const mixedImport = b.importDeclaration( - [ - b.importSpecifier(b.identifier('defer'), b.identifier('_defer')), - b.importSpecifier(b.identifier('execute'), b.identifier('execute')), - ], - b.stringLiteral('@openfn/runtime') - ); - const ast = b.program([ - mixedImport, - createOperationStatement('get'), - b.exportDefaultDeclaration(b.arrayExpression([])), - ]); - - const { body } = transform(ast, [visitors], { - 'top-level-operations': { strip: true }, - }) as n.Program; - - // Only the filtered import remains — _defer removed, execute kept, export default [] gone - t.is(body.length, 1); - t.true(n.ImportDeclaration.check(body[0])); - const imp = body[0] as n.ImportDeclaration; - t.is(imp.specifiers?.length, 1); - t.is((imp.specifiers![0] as n.ImportSpecifier).imported.name, 'execute'); -}); 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 From 5befc82e6eb71a1311a5c1b14eaa650192a4ed9e Mon Sep 17 00:00:00 2001 From: Emmanuel Evance Date: Fri, 5 Jun 2026 11:13:06 +0300 Subject: [PATCH 7/9] prettier format --- packages/cli/src/compile/command.ts | 5 +---- packages/cli/src/compile/compile.ts | 13 +++++++++--- packages/cli/src/compile/handler.ts | 7 ++++++- .../compiler/src/transforms/exports-only.ts | 21 +++++++++++++------ .../src/transforms/top-level-operations.ts | 6 +++++- 5 files changed, 37 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/compile/command.ts b/packages/cli/src/compile/command.ts index 73b96ab8c..d8b09552e 100644 --- a/packages/cli/src/compile/command.ts +++ b/packages/cli/src/compile/command.ts @@ -87,10 +87,7 @@ const compileCommand: yargs.CommandModule = { 'compile foo/job.js --watch', 'Watches the file and recompiles on every change' ) - .example( - 'compile --watch', - 'Compiles all workflows on change' - ), + .example('compile --watch', 'Compiles all workflows on change'), }; export default compileCommand; diff --git a/packages/cli/src/compile/compile.ts b/packages/cli/src/compile/compile.ts index 4f9878aa3..823bd30d2 100644 --- a/packages/cli/src/compile/compile.ts +++ b/packages/cli/src/compile/compile.ts @@ -16,7 +16,9 @@ import type { CompileOptions } from './command'; export type CompiledJob = { code: string; map?: SourceMapWithOperations }; export const hasExportableCode = (code: string): boolean => - /^\s*(export\s+(const|let|var|function|class)|const|let|var|function|class)\s/m.test(code); + /^\s*(export\s+(const|let|var|function|class)|const|let|var|function|class)\s/m.test( + code + ); export default async function ( job: ExecutionPlan, @@ -217,7 +219,10 @@ export const compileProject = async ( const compiledDir = opts.outputStdout ? null - : path.resolve(cwd, opts.outputPath ?? wsConfig.dirs?.compiled ?? 'compiled'); + : path.resolve( + cwd, + opts.outputPath ?? wsConfig.dirs?.compiled ?? 'compiled' + ); if (compiledDir) { log.info(`Compiling project to ${compiledDir}`); @@ -260,7 +265,9 @@ export const compileProject = async ( const stalePath = compiledDir ? path.join(compiledDir, workflow.id, `${step.id}.js`) : null; - log.info(` ${workflow.id}/${step.id} — skipped (no exportable code after stripping)`); + log.info( + ` ${workflow.id}/${step.id} — skipped (no exportable code after stripping)` + ); if (stalePath) stalePaths.push(stalePath); continue; } diff --git a/packages/cli/src/compile/handler.ts b/packages/cli/src/compile/handler.ts index 1db29d7ee..0672ba4e9 100644 --- a/packages/cli/src/compile/handler.ts +++ b/packages/cli/src/compile/handler.ts @@ -71,7 +71,12 @@ const compileHandler = async (options: CompileOptions, logger: Logger) => { logger.info(`${changedPath} changed, recompiling...`); try { if (options.workflowName) { - await compileProject(options, logger, process.cwd(), options.workflowName); + await compileProject( + options, + logger, + process.cwd(), + options.workflowName + ); } else if (!options.path) { await compileProject(options, logger); } else { diff --git a/packages/compiler/src/transforms/exports-only.ts b/packages/compiler/src/transforms/exports-only.ts index 92dbf510c..4572cc967 100644 --- a/packages/compiler/src/transforms/exports-only.ts +++ b/packages/compiler/src/transforms/exports-only.ts @@ -18,15 +18,24 @@ import type { Transformer } from '../transform'; // Recursively collect all Identifier names referenced in a node. // Conservative: includes property names in member expressions (avoids false negatives). -export const collectRefs = (node: any, refs = new Set()): Set => { +export const collectRefs = ( + node: any, + refs = new Set() +): Set => { if (!node || typeof node !== 'object') return refs; if (Array.isArray(node)) { - node.forEach(item => collectRefs(item, refs)); + node.forEach((item) => collectRefs(item, refs)); return refs; } if (n.Identifier.check(node)) refs.add(node.name); for (const key of Object.keys(node)) { - if (key === 'type' || key === 'loc' || key === 'comments' || key === 'tokens') continue; + if ( + key === 'type' || + key === 'loc' || + key === 'comments' || + key === 'tokens' + ) + continue; collectRefs(node[key], refs); } return refs; @@ -83,7 +92,7 @@ function visitor( // Bare (non-export, non-import) top-level statements — candidates for tree-shaking const nonExported = body.filter( - node => + (node) => !n.ImportDeclaration.check(node) && !n.ExportNamedDeclaration.check(node) && !n.ExportDefaultDeclaration.check(node) @@ -92,7 +101,7 @@ function visitor( const declMap = buildDeclMap(nonExported); // Seed tree-shaking from all named export declarations - const exportSeeds = body.filter(node => + const exportSeeds = body.filter((node) => n.ExportNamedDeclaration.check(node) ) as n.Statement[]; @@ -101,7 +110,7 @@ function visitor( // Keep imports, named exports, and their transitive non-exported dependencies programPath.node.body = body.filter( - node => + (node) => n.ImportDeclaration.check(node) || n.ExportNamedDeclaration.check(node) || needed.has(node as n.Statement) diff --git a/packages/compiler/src/transforms/top-level-operations.ts b/packages/compiler/src/transforms/top-level-operations.ts index edbaf799e..d89d7a891 100644 --- a/packages/compiler/src/transforms/top-level-operations.ts +++ b/packages/compiler/src/transforms/top-level-operations.ts @@ -19,7 +19,11 @@ export type TopLevelOpsOptions = { wrap?: boolean; // TODO }; -function visitor(programPath: ExtendedProgram, _logger: any, _options: Partial = {}) { +function visitor( + programPath: ExtendedProgram, + _logger: any, + _options: Partial = {} +) { const operations: Array<{ line: number; name: string; order: number }> = []; const children = programPath.node.body; const rem = []; From 13dc96cf843054b19ca17eb567aeeeffbd17e8a6 Mon Sep 17 00:00:00 2001 From: Emmanuel Evance Date: Fri, 5 Jun 2026 11:51:49 +0300 Subject: [PATCH 8/9] chore: trim noisy comments from compile and transformer files Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/compile/compile.ts | 15 ++--------- packages/cli/src/compile/handler.ts | 9 ------- .../compiler/src/transforms/exports-only.ts | 27 +++---------------- .../src/transforms/top-level-operations.ts | 6 +---- 4 files changed, 6 insertions(+), 51 deletions(-) diff --git a/packages/cli/src/compile/compile.ts b/packages/cli/src/compile/compile.ts index 823bd30d2..6271fa421 100644 --- a/packages/cli/src/compile/compile.ts +++ b/packages/cli/src/compile/compile.ts @@ -77,7 +77,6 @@ const compileJob = async ( } }; -// Find every expression in the job and run the compiler on it const compileWorkflow = async ( plan: ExecutionPlan, opts: CompileOptions, @@ -118,7 +117,6 @@ export const stripVersionSpecifier = (specifier: string) => { return specifier; }; -// Take a module path as provided by the CLI and convert it into a path export const resolveSpecifierPath = async ( pattern: string, repoDir: string | undefined, @@ -127,7 +125,6 @@ export const resolveSpecifierPath = async ( const [specifier, path] = pattern.split('='); if (path) { - // given an explicit path, just load it. log.debug(`Resolved ${specifier} to path: ${path}`); return path; } @@ -139,7 +136,6 @@ export const resolveSpecifierPath = async ( return null; }; -// Mutate the opts object to write export information for the add-imports transformer export const loadTransformOptions = async ( opts: CompileOptions, log: Logger @@ -151,19 +147,16 @@ export const loadTransformOptions = async ( if (opts.exportsOnly) { options['exports-only'] = true; - // Disable transformers that produce output not needed for unit testing + // ensure-exports and top-level-operations produce output incompatible with exports-only mode options['ensure-exports'] = false; options['top-level-operations'] = false; } - // If an adaptor is passed in, we need to look up its declared exports - // and pass them along to the compiler if (opts.adaptors?.length && opts.ignoreImports != true) { const adaptorsConfig = []; for (const adaptorInput of opts.adaptors) { let exports; const [specifier] = adaptorInput.split('='); - // Preload exports from a path, optionally logging errors in case of a failure log.debug(`Trying to preload types for ${specifier}`); const path = await resolveSpecifierPath(adaptorInput, opts.repoDir, log); if (path) { @@ -195,9 +188,6 @@ export const loadTransformOptions = async ( return options; }; -// Compile all steps across all workflows in the current project. -// Writes one .js file per step to compiledDir//.js. -// Pass workflowFilter to compile a single workflow by id or name. export const compileProject = async ( opts: CompileOptions, log: Logger, @@ -284,8 +274,7 @@ export const compileProject = async ( } } - // Remove stale step files left over from a previous run with different flags. - // Only deletes files at exact step paths — user-added files with other names are untouched. + // Only deletes exact step paths — user-added files alongside them are untouched. for (const stalePath of stalePaths) { try { await fs.unlink(stalePath); diff --git a/packages/cli/src/compile/handler.ts b/packages/cli/src/compile/handler.ts index 0672ba4e9..fa2504464 100644 --- a/packages/cli/src/compile/handler.ts +++ b/packages/cli/src/compile/handler.ts @@ -8,22 +8,16 @@ import compile, { compileProject } from './compile'; import loadPlan from '../util/load-plan'; import assertPath from '../util/assert-path'; -// Derive the watch target(s) from the compile options. -// Returns either file paths or directory globs that chokidar can watch. const collectWatchTargets = (options: CompileOptions): string[] => { if (options.expressionPath) { return [path.resolve(options.expressionPath)]; } if (options.path) { - // Workflow/plan mode: watch the workflow file return [path.resolve(options.path)]; } - // Project mode: watch the entire workflows directory for .js changes return [path.join(process.cwd(), 'workflows', '**', '*.js')]; }; -// Compile a single file (.js expression or .json/.yaml workflow) and print or save result. -// Defaults to stdout unless -o is given. const doCompile = async (options: CompileOptions, logger: Logger) => { let result: string; if (options.expressionPath) { @@ -46,13 +40,10 @@ const doCompile = async (options: CompileOptions, logger: Logger) => { const compileHandler = async (options: CompileOptions, logger: Logger) => { if (options.workflowName) { - // Workflow name: look it up in the project and compile to disk (or stdout with -O) await compileProject(options, logger, process.cwd(), options.workflowName); } else if (!options.path) { - // No path: compile the whole project to disk (or stdout with -O) await compileProject(options, logger); } else { - // File path (.js / .json / .yaml): compile and print to stdout (or -o file) assertPath(options.path); await doCompile(options, logger); } diff --git a/packages/compiler/src/transforms/exports-only.ts b/packages/compiler/src/transforms/exports-only.ts index 4572cc967..3abb0105e 100644 --- a/packages/compiler/src/transforms/exports-only.ts +++ b/packages/compiler/src/transforms/exports-only.ts @@ -1,23 +1,11 @@ -/* - * Strip all non-exported top-level code. - * - * Keeps: import declarations and named export declarations. - * Drops: expression statements (operations), export default, and bare - * top-level declarations that are not referenced by any kept export. - * - * Non-exported declarations that are transitively referenced by a kept - * export declaration are preserved (tree-shaking). - * - * This transformer is designed to run before all others (order: 0). - * It is a no-op unless explicitly enabled via options['exports-only'] = true. - */ +// Strips all non-exported top-level code (operations, export default, bare declarations). +// Runs before all other transformers (order: 0). No-op unless options === true. import { namedTypes as n } from 'ast-types'; import type { NodePath } from 'ast-types/lib/node-path'; import type { Transformer } from '../transform'; -// Recursively collect all Identifier names referenced in a node. -// Conservative: includes property names in member expressions (avoids false negatives). +// Conservative: includes member expression property names to avoid false negatives. export const collectRefs = ( node: any, refs = new Set() @@ -41,7 +29,6 @@ export const collectRefs = ( return refs; }; -// Build a map from declared name → statement for non-export top-level declarations. export const buildDeclMap = ( nodes: n.Statement[] ): Map => { @@ -60,7 +47,6 @@ export const buildDeclMap = ( return map; }; -// Transitively collect all declarations that the seed nodes depend on. export const collectDeps = ( seeds: n.Statement[], declMap: Map @@ -85,12 +71,10 @@ function visitor( _logger: any, options: boolean | {} = {} ) { - // Only run when explicitly enabled if (options !== true) return; const body = programPath.node.body; - // Bare (non-export, non-import) top-level statements — candidates for tree-shaking const nonExported = body.filter( (node) => !n.ImportDeclaration.check(node) && @@ -99,16 +83,11 @@ function visitor( ) as n.Statement[]; const declMap = buildDeclMap(nonExported); - - // Seed tree-shaking from all named export declarations const exportSeeds = body.filter((node) => n.ExportNamedDeclaration.check(node) ) as n.Statement[]; - - // Transitively collect non-exported declarations that exports depend on const needed = collectDeps(exportSeeds, declMap); - // Keep imports, named exports, and their transitive non-exported dependencies programPath.node.body = body.filter( (node) => n.ImportDeclaration.check(node) || diff --git a/packages/compiler/src/transforms/top-level-operations.ts b/packages/compiler/src/transforms/top-level-operations.ts index d89d7a891..18f19bc54 100644 --- a/packages/compiler/src/transforms/top-level-operations.ts +++ b/packages/compiler/src/transforms/top-level-operations.ts @@ -5,8 +5,6 @@ import { namedTypes as n, namedTypes } from 'ast-types'; import type { NodePath } from 'ast-types/lib/node-path'; 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< namedTypes.Program & { @@ -48,12 +46,10 @@ function visitor( } programPath.node.body = rem; } else { - // error! there isn't an appropriate export statement - // What do we do? + // no export default [] — nothing to move operations into } programPath.node.operations = operations; - // if not (for now) we should cancel traversal return true; } From ae1b1b8a15a45f1ca75fa8e2dfdc0541ddcffccc Mon Sep 17 00:00:00 2001 From: Emmanuel Evance Date: Fri, 5 Jun 2026 15:17:53 +0300 Subject: [PATCH 9/9] pnpm test:format --- .../test/transforms/exports-only.test.ts | 88 ++++++++++++++----- 1 file changed, 67 insertions(+), 21 deletions(-) diff --git a/packages/compiler/test/transforms/exports-only.test.ts b/packages/compiler/test/transforms/exports-only.test.ts index 980ff426a..a3ee658f4 100644 --- a/packages/compiler/test/transforms/exports-only.test.ts +++ b/packages/compiler/test/transforms/exports-only.test.ts @@ -10,12 +10,17 @@ import visitors, { // Helpers const makeConst = (name: string, value: any = b.literal(42)) => - b.variableDeclaration('const', [b.variableDeclarator(b.identifier(name), value)]); + b.variableDeclaration('const', [ + b.variableDeclarator(b.identifier(name), value), + ]); const makeExportConst = (name: string, value: any = b.literal(42)) => b.exportNamedDeclaration(makeConst(name, value), []); -const makeExportFn = (name: string, body: any[] = [b.returnStatement(b.literal(1))]) => +const makeExportFn = ( + name: string, + body: any[] = [b.returnStatement(b.literal(1))] +) => b.exportNamedDeclaration( b.functionDeclaration(b.identifier(name), [], b.blockStatement(body)), [] @@ -60,7 +65,11 @@ test('buildDeclMap: maps variable declarations', (t) => { }); test('buildDeclMap: maps function declarations', (t) => { - const decl = b.functionDeclaration(b.identifier('myFn'), [], b.blockStatement([])); + const decl = b.functionDeclaration( + b.identifier('myFn'), + [], + b.blockStatement([]) + ); const map = buildDeclMap([decl]); t.true(map.has('myFn')); }); @@ -99,7 +108,10 @@ test('collectDeps: transitively follows dependencies', (t) => { // --- exports-only transformer --- test('is a no-op when options is not true', (t) => { - const ast = b.program([makeOp('fn'), b.exportDefaultDeclaration(b.arrayExpression([]))]); + const ast = b.program([ + makeOp('fn'), + b.exportDefaultDeclaration(b.arrayExpression([])), + ]); const before = print(ast).code; const after = print(transform(ast, [visitors])).code; t.is(before, after); @@ -108,13 +120,17 @@ test('is a no-op when options is not true', (t) => { test('is a no-op when options is false', (t) => { const ast = b.program([makeOp('fn')]); const before = print(ast).code; - const after = print(transform(ast, [visitors], { 'exports-only': false })).code; + const after = print( + transform(ast, [visitors], { 'exports-only': false }) + ).code; t.is(before, after); }); test('strips operation calls', (t) => { const ast = b.program([makeOp('get'), makeOp('fn')]); - const { body } = transform(ast, [visitors], { 'exports-only': true }) as n.Program; + const { body } = transform(ast, [visitors], { + 'exports-only': true, + }) as n.Program; t.is(body.length, 0); }); @@ -123,20 +139,26 @@ test('strips export default []', (t) => { makeOp('fn'), b.exportDefaultDeclaration(b.arrayExpression([])), ]); - const { body } = transform(ast, [visitors], { 'exports-only': true }) as n.Program; + const { body } = transform(ast, [visitors], { + 'exports-only': true, + }) as n.Program; t.is(body.length, 0); }); test('strips non-exported declarations', (t) => { const ast = b.program([makeConst('x'), makeOp('fn')]); - const { body } = transform(ast, [visitors], { 'exports-only': true }) as n.Program; + const { body } = transform(ast, [visitors], { + 'exports-only': true, + }) as n.Program; t.is(body.length, 0); }); test('keeps import declarations', (t) => { const imp = makeImport('get', '@openfn/language-http'); const ast = b.program([imp, makeOp('fn')]); - const { body } = transform(ast, [visitors], { 'exports-only': true }) as n.Program; + const { body } = transform(ast, [visitors], { + 'exports-only': true, + }) as n.Program; t.is(body.length, 1); t.true(n.ImportDeclaration.check(body[0])); }); @@ -144,14 +166,18 @@ test('keeps import declarations', (t) => { test('keeps named export declarations', (t) => { const exported = makeExportConst('helper'); const ast = b.program([exported, makeOp('fn')]); - const { body } = transform(ast, [visitors], { 'exports-only': true }) as n.Program; + const { body } = transform(ast, [visitors], { + 'exports-only': true, + }) as n.Program; t.is(body.length, 1); t.true(n.ExportNamedDeclaration.check(body[0])); }); test('keeps exported function declarations', (t) => { const ast = b.program([makeExportFn('formatDate'), makeOp('fn')]); - const { body } = transform(ast, [visitors], { 'exports-only': true }) as n.Program; + const { body } = transform(ast, [visitors], { + 'exports-only': true, + }) as n.Program; t.is(body.length, 1); t.true(n.ExportNamedDeclaration.check(body[0])); }); @@ -162,10 +188,14 @@ test('keeps non-exported declarations that an export depends on', (t) => { b.arrowFunctionExpression([b.identifier('d')], b.identifier('d')) ); const exported = makeExportFn('formatDate', [ - b.returnStatement(b.callExpression(b.identifier('fmt'), [b.identifier('date')])), + b.returnStatement( + b.callExpression(b.identifier('fmt'), [b.identifier('date')]) + ), ]); const ast = b.program([helper, exported, makeOp('fn')]); - const { body } = transform(ast, [visitors], { 'exports-only': true }) as n.Program; + const { body } = transform(ast, [visitors], { + 'exports-only': true, + }) as n.Program; t.is(body.length, 2); t.true(n.VariableDeclaration.check(body[0])); t.true(n.ExportNamedDeclaration.check(body[1])); @@ -173,9 +203,13 @@ test('keeps non-exported declarations that an export depends on', (t) => { test('drops non-exported declarations that no export depends on', (t) => { const unused = makeConst('unused'); - const exported = makeExportFn('greet', [b.returnStatement(b.stringLiteral('hi'))]); + const exported = makeExportFn('greet', [ + b.returnStatement(b.stringLiteral('hi')), + ]); const ast = b.program([unused, exported, makeOp('fn')]); - const { body } = transform(ast, [visitors], { 'exports-only': true }) as n.Program; + const { body } = transform(ast, [visitors], { + 'exports-only': true, + }) as n.Program; t.is(body.length, 1); t.true(n.ExportNamedDeclaration.check(body[0])); }); @@ -194,7 +228,9 @@ test('follows transitive dependencies', (t) => { b.returnStatement(b.callExpression(b.identifier('middle'), [])), ]); const ast = b.program([leaf, middle, exported, makeOp('fn')]); - const { body } = transform(ast, [visitors], { 'exports-only': true }) as n.Program; + const { body } = transform(ast, [visitors], { + 'exports-only': true, + }) as n.Program; // leaf + middle + exported — no op, no export default t.is(body.length, 3); }); @@ -203,7 +239,9 @@ test('keeps imports alongside named exports', (t) => { const imp = makeImport('dateFns', '@openfn/language-dhis2'); const exported = makeExportConst('formatDate'); const ast = b.program([imp, exported, makeOp('fn')]); - const { body } = transform(ast, [visitors], { 'exports-only': true }) as n.Program; + const { body } = transform(ast, [visitors], { + 'exports-only': true, + }) as n.Program; t.is(body.length, 2); t.true(n.ImportDeclaration.check(body[0])); t.true(n.ExportNamedDeclaration.check(body[1])); @@ -211,13 +249,17 @@ test('keeps imports alongside named exports', (t) => { test('handles a file with only operations (no exports)', (t) => { const ast = b.program([makeOp('fn'), makeOp('get')]); - const { body } = transform(ast, [visitors], { 'exports-only': true }) as n.Program; + const { body } = transform(ast, [visitors], { + 'exports-only': true, + }) as n.Program; t.is(body.length, 0); }); test('handles an empty file', (t) => { const ast = b.program([]); - const { body } = transform(ast, [visitors], { 'exports-only': true }) as n.Program; + const { body } = transform(ast, [visitors], { + 'exports-only': true, + }) as n.Program; t.is(body.length, 0); }); @@ -227,7 +269,9 @@ test('handles multiple exports without operations', (t) => { makeExportConst('b'), makeExportFn('c'), ]); - const { body } = transform(ast, [visitors], { 'exports-only': true }) as n.Program; + const { body } = transform(ast, [visitors], { + 'exports-only': true, + }) as n.Program; t.is(body.length, 3); }); @@ -236,6 +280,8 @@ test('does not remove export default when exports-only is disabled', (t) => { makeOp('fn'), b.exportDefaultDeclaration(b.arrayExpression([])), ]); - const { body } = transform(ast, [visitors], { 'exports-only': false }) as n.Program; + const { body } = transform(ast, [visitors], { + 'exports-only': false, + }) as n.Program; t.is(body.length, 2); });