diff --git a/.claude/unit-testing-jobs.md b/.claude/unit-testing-jobs.md
new file mode 100644
index 000000000..d259af780
--- /dev/null
+++ b/.claude/unit-testing-jobs.md
@@ -0,0 +1,149 @@
+# 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
+
+`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 all workflows in the project (full compilation, preserves operations)
+openfn compile
+
+# Compile all workflows, stripping operation calls (useful for unit testing)
+openfn compile --exports-only
+
+# Compile a single workflow by name
+openfn compile my-workflow
+
+# 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 --exports-only --watch
+```
+
+## What `--exports-only` keeps
+
+`--exports-only` strips operation calls. Only explicitly exported declarations survive:
+
+- `export const myHelper = ...` ✓
+- `export function parseSms() {}` ✓
+- `const helper = ...` (not exported) ✗ — dropped
+- `fn(state => ...)` (operation call) ✗ — stripped
+
+`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.
+
+## Full compilation (no `--exports-only`)
+
+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';
+
+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** (`compiled/dhis2-sync/transform.js`) after `openfn compile --exports-only`:
+
+```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 '../compiled/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` with no path compiles every step in every workflow in the current project directory (must contain `openfn.yaml`).
+
+Output layout:
+
+```
+compiled/
+ my-workflow/
+ step-a.js
+ step-b.js
+ another-workflow/
+ step-c.js
+```
+
+Override the output directory with `-o
`:
+
+```bash
+openfn compile --exports-only -o tests/
+```
+
+Configure default directories in `openfn.yaml`:
+
+```yaml
+dirs:
+ workflows: workflows
+ compiled: compiled # used by openfn compile
+```
+
+## Recommended Setup
+
+**`package.json`** (in your OpenFn project):
+
+```json
+{
+ "scripts": {
+ "compile": "openfn compile --exports-only",
+ "compile:watch": "openfn compile --exports-only --watch",
+ "test": "node --test test/**/*.test.js"
+ }
+}
+```
+
+## Notes
+
+- In `--exports-only` mode, only `export const` and `export function` declarations survive — non-exported helpers are always dropped
+- Import statements are always preserved
+- 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/CLAUDE.md b/CLAUDE.md
index c1744b67e..553b0d925 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -131,6 +131,7 @@ cd packages/cli && pnpm test:watch # Watch mode
The [.claude](.claude) folder contains detailed guides:
- **[event-processor.md](.claude/event-processor.md)** - Worker event processing deep-dive (ordering, batching) — companion to `packages/ws-worker/CLAUDE.md`
+- **[unit-testing-jobs.md](.claude/unit-testing-jobs.md)** - How to unit-test job code using `openfn compile --exports-only`
Key packages also carry their own `CLAUDE.md` (runtime, engine-multi, ws-worker), auto-loaded when you work in them.
diff --git a/packages/cli/package.json b/packages/cli/package.json
index ade33cf32..5af4956c1 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..d8b09552e 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'
@@ -19,6 +20,8 @@ export type CompileOptions = Pick<
| 'useAdaptorsMonorepo'
| 'globals'
| 'trace'
+ | 'watch'
+ | 'workflowName'
> & {
workflow?: Opts['workflow'];
repoDir?: string;
@@ -27,31 +30,30 @@ 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.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,8 +61,33 @@ 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 compiled/'
+ )
+ .example(
+ 'compile my-workflow',
+ 'Compiles a single workflow by name and writes JS files to compiled/'
+ )
+ .example(
+ '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 --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 822188702..c765d4729 100644
--- a/packages/cli/src/compile/compile.ts
+++ b/packages/cli/src/compile/compile.ts
@@ -1,10 +1,14 @@
+import path from 'node:path';
+import fs from 'node:fs/promises';
import compile, {
preloadAdaptorExports,
Options,
getExports,
+ hasExportableCode,
} 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';
@@ -69,7 +73,6 @@ const compileJob = async (
}
};
-// Find every expression in the job and run the compiler on it
const compileWorkflow = async (
plan: ExecutionPlan,
opts: CompileOptions,
@@ -110,7 +113,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,
@@ -119,7 +121,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;
}
@@ -131,7 +132,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
@@ -140,15 +140,19 @@ export const loadTransformOptions = async (
logger: log || createLogger(COMPILER, opts as any),
trace: opts.trace,
};
- // If an adaptor is passed in, we need to look up its declared exports
- // and pass them along to the compiler
+
+ if (opts.exportsOnly) {
+ options['exports-only'] = true;
+ // ensure-exports and top-level-operations produce output incompatible with exports-only mode
+ options['ensure-exports'] = false;
+ options['top-level-operations'] = false;
+ }
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) {
@@ -179,3 +183,89 @@ export const loadTransformOptions = async (
return options;
};
+
+export const compileProject = async (
+ opts: CompileOptions,
+ log: Logger,
+ 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);
+ 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 = 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 allSteps = workflows.flatMap((wf: any) =>
+ wf.steps
+ .filter((step: any) => step.expression)
+ .map((step: any) => ({ workflow: wf, step }))
+ );
+
+ for (const { workflow, step } of allSteps) {
+ const stepOpts: CompileOptions = {
+ ...opts,
+ adaptors: step.adaptor ? [step.adaptor] : opts.adaptors ?? [],
+ };
+
+ const { code } = await compileJob(
+ step.expression,
+ stepOpts,
+ log,
+ step.name ?? step.id
+ );
+
+ const stepId = `${workflow.id}/${step.id}`;
+
+ if (opts.exportsOnly && !hasExportableCode(code)) {
+ log.debug(` ${stepId} — skipped (no exportable code)`);
+ continue;
+ }
+
+ if (opts.outputStdout) {
+ log.success(`// ${stepId}\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(` ${stepId} → ${outPath}`);
+ }
+ }
+
+ 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 4c75415cc..3c8d9e237 100644
--- a/packages/cli/src/compile/handler.ts
+++ b/packages/cli/src/compile/handler.ts
@@ -1,16 +1,25 @@
-import { mkdir, writeFile } from 'node:fs/promises';
-import { dirname } from 'node:path';
+import path from 'node:path';
+import { writeFile, mkdir } from 'node:fs/promises';
+import chokidar from 'chokidar';
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);
+const collectWatchTargets = (options: CompileOptions): string[] => {
+ if (options.expressionPath) {
+ return [path.resolve(options.expressionPath)];
+ }
+ if (options.path) {
+ return [path.resolve(options.path)];
+ }
+ return [path.join(process.cwd(), 'workflows', '**', '*.js')];
+};
- let result;
+const doCompile = async (options: CompileOptions, logger: Logger) => {
+ let result: string;
if (options.expressionPath) {
const { code } = await compile(options.expressionPath, options, logger);
result = code;
@@ -20,13 +29,50 @@ const compileHandler = async (options: CompileOptions, logger: Logger) => {
result = JSON.stringify(compiledPlan, null, 2);
}
- if (options.outputStdout) {
+ if (options.outputPath) {
+ await mkdir(path.dirname(options.outputPath), { recursive: true });
+ await writeFile(options.outputPath, result);
+ logger.success(`Compiled to ${options.outputPath}`);
+ } else {
logger.success('Result:\n\n' + result);
+ }
+};
+
+const runCompile = async (options: CompileOptions, logger: Logger) => {
+ if (options.workflowName) {
+ await compileProject(options, logger, process.cwd(), options.workflowName);
+ } else if (!options.path) {
+ await compileProject(options, logger);
} else {
- await mkdir(dirname(options.outputPath!), { recursive: true });
- await writeFile(options.outputPath!, result as string);
- logger.success(`Compiled to ${options.outputPath}`);
+ assertPath(options.path);
+ await doCompile(options, logger);
}
};
+const compileHandler = async (options: CompileOptions, logger: Logger) => {
+ await runCompile(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/**', '**/compiled/**'],
+ });
+
+ watcher.on('change', async (changedPath: string) => {
+ logger.info(`${changedPath} changed, recompiling...`);
+ try {
+ await runCompile(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 64087498b..557560574 100644
--- a/packages/cli/src/options.ts
+++ b/packages/cli/src/options.ts
@@ -66,8 +66,10 @@ export type Opts = {
statePath?: string;
stateStdin?: string;
timeout?: number; // ms
+ exportsOnly?: boolean;
trace?: boolean;
useAdaptorsMonorepo?: boolean;
+ watch?: boolean;
workflow: string;
workflowName?: string;
validate?: boolean;
@@ -632,6 +634,26 @@ export const validate: CLIOption = {
},
};
+export const exportsOnly: CLIOption = {
+ name: 'exports-only',
+ yargs: {
+ boolean: true,
+ description:
+ 'Strip adaptor operation calls, exporting only constants and functions',
+ default: false,
+ },
+};
+
+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 6acdb9bf8..f29469561 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';
@@ -342,3 +343,135 @@ test.serial('loadTransformOptions: ignore some imports', async (t) => {
});
// TODO test exception if the module can't be found
+
+test.serial(
+ 'loadTransformOptions: --exports-only enables exports-only transformer',
+ async (t) => {
+ const opts = { exportsOnly: true } as CompileOptions;
+ const result = await loadTransformOptions(opts, mockLog);
+ t.is(result['exports-only'], true);
+ t.is(result['ensure-exports'], false);
+ t.is(result['top-level-operations'], false);
+ }
+);
+
+test.serial(
+ 'loadTransformOptions: --exports-only does not affect other options',
+ async (t) => {
+ const opts = { exportsOnly: 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: skips steps with no exportable code 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`;
+
+ 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();"
+`,
+ });
+
+ const outPaths = await compileProject(
+ { exportsOnly: true } as CompileOptions,
+ mockLog,
+ '/proj'
+ );
+
+ // Only step-a has exportable code — step-b should not be written
+ t.is(outPaths.length, 1);
+ t.true(outPaths[0].endsWith('step-a.js'));
+
+ 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..f329a4e02
--- /dev/null
+++ b/packages/cli/test/compile/handler.test.ts
@@ -0,0 +1,7 @@
+import test from 'ava';
+
+// 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/index.ts b/packages/compiler/src/index.ts
index 401388960..de346a5f6 100644
--- a/packages/compiler/src/index.ts
+++ b/packages/compiler/src/index.ts
@@ -1,5 +1,6 @@
import compile from './compile';
export { default as getExports } from './get-exports';
+export { hasExportableCode } from './transforms/exports-only';
export * from './util';
export type { TransformOptions } from './transform';
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..1325906ef
--- /dev/null
+++ b/packages/compiler/src/transforms/exports-only.ts
@@ -0,0 +1,33 @@
+/*
+ * Strips all non-exported top-level code (operations, export default, bare declarations).
+ */
+
+import { namedTypes as n } from 'ast-types';
+import type { NodePath } from 'ast-types/lib/node-path';
+import type { Transformer } from '../transform';
+
+// Returns true if compiled output contains any declarations worth importing in tests.
+export const hasExportableCode = (code: string): boolean =>
+ /^\s*export\s+(const|let|var|function|class)\s/m.test(code);
+
+function visitor(
+ programPath: NodePath,
+ _logger: any,
+ options: boolean | {} = {}
+) {
+ if (options !== true) return;
+
+ programPath.node.body = programPath.node.body.filter(
+ (node) =>
+ n.ImportDeclaration.check(node) || n.ExportNamedDeclaration.check(node)
+ ) 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/test/compile.test.ts b/packages/compiler/test/compile.test.ts
index 614e26344..0f44148f5 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);
});
+
+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, exportsOnlyOpts);
+
+ t.true(result.includes('export const formatDate'));
+ t.false(result.includes('get('));
+ t.false(result.includes('fn(state'));
+ t.false(result.includes('export default []'));
+});
+
+test('exports-only: removes non-exported declarations', (t) => {
+ const source = [
+ 'const formatDate = (d) => d.toISOString();',
+ 'get("/api");',
+ ].join('\n');
+
+ const { code: result } = compile(source, exportsOnlyOpts);
+
+ // 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('exports-only: 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, exportsOnlyOpts);
+
+ 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/exports-only.test.ts b/packages/compiler/test/transforms/exports-only.test.ts
new file mode 100644
index 000000000..2751aafc5
--- /dev/null
+++ b/packages/compiler/test/transforms/exports-only.test.ts
@@ -0,0 +1,142 @@
+import test from 'ava';
+import { print } from 'recast';
+import parse from '../../src/parse';
+import transform from '../../src/transform';
+import visitors, { hasExportableCode } from '../../src/transforms/exports-only';
+
+const compile = (source: string, options = {}) =>
+ print(transform(parse(source), [visitors], options)).code.trim();
+
+// --- hasExportableCode ---
+
+test('hasExportableCode: true for export const', (t) => {
+ t.true(hasExportableCode('export const x = 1;'));
+});
+
+test('hasExportableCode: true for export function', (t) => {
+ t.true(hasExportableCode('export function foo() {}'));
+});
+
+test('hasExportableCode: true for export let', (t) => {
+ t.true(hasExportableCode('export let x = 1;'));
+});
+
+test('hasExportableCode: true for export var', (t) => {
+ t.true(hasExportableCode('export var x = 1;'));
+});
+
+test('hasExportableCode: true for export class', (t) => {
+ t.true(hasExportableCode('export class Foo {}'));
+});
+
+test('hasExportableCode: false for import only', (t) => {
+ t.false(hasExportableCode("import { get } from '@openfn/language-http';"));
+});
+
+test('hasExportableCode: false for empty string', (t) => {
+ t.false(hasExportableCode(''));
+});
+
+test('hasExportableCode: false for operations only', (t) => {
+ t.false(hasExportableCode('fn();\nget();'));
+});
+
+test('is a no-op when options is not true', (t) => {
+ const before = `fn();
+export default [];`;
+ t.is(compile(before), before);
+});
+
+test('is a no-op when options is false', (t) => {
+ const before = `fn();`;
+ t.is(compile(before, { 'exports-only': false }), before);
+});
+
+test('strips operation calls', (t) => {
+ const before = `get();
+fn();`;
+ t.is(compile(before, { 'exports-only': true }), '');
+});
+
+test('strips export default []', (t) => {
+ const before = `fn();
+export default [];`;
+ t.is(compile(before, { 'exports-only': true }), '');
+});
+
+test('strips non-exported declarations', (t) => {
+ const before = `const x = 42;
+fn();`;
+ t.is(compile(before, { 'exports-only': true }), '');
+});
+
+test('keeps import declarations', (t) => {
+ const before = `import { get } from '@openfn/language-http';
+fn();`;
+ const after = `import { get } from '@openfn/language-http';`;
+ t.is(compile(before, { 'exports-only': true }), after);
+});
+
+test('keeps named export declarations', (t) => {
+ const before = `export const helper = 42;
+fn();`;
+ const after = `export const helper = 42;`;
+ t.is(compile(before, { 'exports-only': true }), after);
+});
+
+test('keeps exported function declarations', (t) => {
+ const before = `export function formatDate() {
+ return 1;
+}
+fn();`;
+ const after = `export function formatDate() {
+ return 1;
+}`;
+ t.is(compile(before, { 'exports-only': true }), after);
+});
+
+test('drops non-exported declarations that no export depends on', (t) => {
+ const before = `const unused = 42;
+export function greet() {
+ return 'hi';
+}
+fn();`;
+ const after = `export function greet() {
+ return 'hi';
+}`;
+ t.is(compile(before, { 'exports-only': true }), after);
+});
+
+test('keeps imports alongside named exports', (t) => {
+ const before = `import { dateFns } from '@openfn/language-dhis2';
+export const formatDate = 42;
+fn();`;
+ const after = `import { dateFns } from '@openfn/language-dhis2';
+export const formatDate = 42;`;
+ t.is(compile(before, { 'exports-only': true }), after);
+});
+
+test('handles a file with only operations (no exports)', (t) => {
+ const before = `fn();
+get();`;
+ t.is(compile(before, { 'exports-only': true }), '');
+});
+
+test('handles an empty file', (t) => {
+ t.is(compile('', { 'exports-only': true }), '');
+});
+
+test('handles multiple exports without operations', (t) => {
+ const source = `export const a = 42;
+export const b = 42;
+export function c() {
+ return 1;
+}`;
+ t.is(compile(source, { 'exports-only': true }), source);
+});
+
+test('does not remove export default when exports-only is disabled', (t) => {
+ const before = `fn();
+export default [];`;
+ t.is(compile(before, { 'exports-only': false }), before);
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f6c86ac69..26f60dd98 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