diff --git a/packages/build-tools/src/common/jobHooks.ts b/packages/build-tools/src/common/jobHooks.ts index da56ae39bb..f4913bcdc2 100644 --- a/packages/build-tools/src/common/jobHooks.ts +++ b/packages/build-tools/src/common/jobHooks.ts @@ -1,9 +1,9 @@ import { BuildJob, - CompositeFunctionCatalog, ErrorCode, HookAnchorId, HookKey, + LocalFunctionCatalog, UserError, parseHookKey, validateSteps, @@ -98,7 +98,7 @@ export async function parseJobHooksAsync( // outputs accumulate across keys. const hookEntriesByKey: Partial> = {}; const orderedSteps: BuildStep[] = []; - const compositeFunctionCatalog: CompositeFunctionCatalog = {}; + const compositeFunctionCatalog: LocalFunctionCatalog = {}; const loadCompositeFunction = createLocalCompositeFunctionLoader( ctx.getReactNativeProjectDirectory(), { logger: ctx.logger } diff --git a/packages/eas-build-job/src/__tests__/compositeFunction.test.ts b/packages/eas-build-job/src/__tests__/compositeFunction.test.ts index cb8b5e4016..b7739dca0e 100644 --- a/packages/eas-build-job/src/__tests__/compositeFunction.test.ts +++ b/packages/eas-build-job/src/__tests__/compositeFunction.test.ts @@ -75,4 +75,12 @@ describe('CompositeFunctionConfigZ', () => { ); } }); + + it('rejects the single-step output shape', () => { + const config = { + outputs: [{ name: 'version' }], + runs: { steps: [{ run: 'echo hello' }] }, + }; + expect(() => CompositeFunctionConfigZ.parse(config)).toThrow(ZodError); + }); }); diff --git a/packages/eas-build-job/src/__tests__/legacyFunction.test.ts b/packages/eas-build-job/src/__tests__/legacyFunction.test.ts new file mode 100644 index 0000000000..24a2389b71 --- /dev/null +++ b/packages/eas-build-job/src/__tests__/legacyFunction.test.ts @@ -0,0 +1,94 @@ +import { ZodError } from 'zod'; + +import { LegacyCommandFunctionConfigZ, LegacyPathFunctionConfigZ } from '../legacyFunction'; + +describe('LegacyCommandFunctionConfigZ', () => { + it('accepts a command function with inputs, outputs, shell and supported platforms', () => { + const config = { + name: 'Say hi', + inputs: [ + 'name', + { name: 'greeting', type: 'string', default_value: 'Hi', allowed_values: ['Hi', 'Hello'] }, + { name: 'loud', type: 'boolean', required: false }, + { name: 'suffix' }, + ], + outputs: ['greeted', { name: 'skipped', required: false }], + command: 'echo "${ inputs.greeting }, ${ inputs.name }!"', + shell: 'sh', + supported_platforms: ['darwin', 'linux'], + }; + expect(LegacyCommandFunctionConfigZ.parse(config)).toEqual({ + ...config, + inputs: [ + 'name', + { name: 'greeting', type: 'string', default_value: 'Hi', allowed_values: ['Hi', 'Hello'] }, + { name: 'loud', type: 'boolean', required: false }, + { name: 'suffix', type: 'string' }, + ], + }); + }); + + it('accepts a minimal config with only command', () => { + const config = { command: 'echo hi' }; + expect(LegacyCommandFunctionConfigZ.parse(config)).toEqual(config); + }); + + it('rejects unknown top-level keys', () => { + const config = { command: 'echo hi', runz: {} }; + expect(() => LegacyCommandFunctionConfigZ.parse(config)).toThrow(ZodError); + expect(() => LegacyCommandFunctionConfigZ.parse(config)).toThrow(/runz/); + }); + + it('rejects description, which the legacy shape does not support', () => { + const config = { command: 'echo hi', description: 'x' }; + expect(() => LegacyCommandFunctionConfigZ.parse(config)).toThrow(ZodError); + }); + + it('rejects unknown supported platforms', () => { + const config = { command: 'echo hi', supported_platforms: ['windows'] }; + expect(() => LegacyCommandFunctionConfigZ.parse(config)).toThrow(ZodError); + }); + + it('rejects the composite output shape', () => { + const config = { command: 'echo hi', outputs: { version: { value: '1.0.0' } } }; + expect(() => LegacyCommandFunctionConfigZ.parse(config)).toThrow(ZodError); + }); + + it('rejects a config declaring runs.steps', () => { + const config = { command: 'echo hi', runs: { steps: [{ run: 'echo hello' }] } }; + expect(() => LegacyCommandFunctionConfigZ.parse(config)).toThrow(ZodError); + }); + + it('rejects a config also declaring path', () => { + const config = { command: 'echo hi', path: './fn' }; + expect(() => LegacyCommandFunctionConfigZ.parse(config)).toThrow(ZodError); + }); +}); + +describe('LegacyPathFunctionConfigZ', () => { + it('accepts a minimal config with only path', () => { + const config = { path: './my-function' }; + expect(LegacyPathFunctionConfigZ.parse(config)).toEqual(config); + }); + + it('accepts shell alongside path', () => { + const config = { path: './my-function', shell: 'sh' }; + expect(LegacyPathFunctionConfigZ.parse(config)).toEqual(config); + }); + + it('rejects unknown top-level keys', () => { + const config = { path: './fn', run: 'echo' }; + expect(() => LegacyPathFunctionConfigZ.parse(config)).toThrow(ZodError); + expect(() => LegacyPathFunctionConfigZ.parse(config)).toThrow(/run/); + }); + + it('rejects a config declaring runs.steps', () => { + const config = { path: './fn', runs: { steps: [{ run: 'echo hello' }] } }; + expect(() => LegacyPathFunctionConfigZ.parse(config)).toThrow(ZodError); + }); + + it('rejects a config also declaring command', () => { + const config = { path: './fn', command: 'echo hi' }; + expect(() => LegacyPathFunctionConfigZ.parse(config)).toThrow(ZodError); + }); +}); diff --git a/packages/eas-build-job/src/__tests__/localFunction.test.ts b/packages/eas-build-job/src/__tests__/localFunction.test.ts new file mode 100644 index 0000000000..b203f4ef16 --- /dev/null +++ b/packages/eas-build-job/src/__tests__/localFunction.test.ts @@ -0,0 +1,88 @@ +import { z } from 'zod'; + +import { LocalFunctionConfigZ, isLegacyFunctionConfig } from '../localFunction'; + +function parseErrorMessages(schema: z.ZodType, config: unknown): string[] { + const result = schema.safeParse(config); + expect(result.success).toBe(false); + return result.error!.issues.map(issue => issue.message); +} + +const UNION_ERROR_MESSAGE = + 'A local function must declare exactly one of "runs.steps" (a composite function), "command" (a shell script) or "path" (a JavaScript function module), and its fields must match that shape.'; + +describe('LocalFunctionConfigZ', () => { + it('parses a composite function', () => { + const config = { + name: 'Setup', + inputs: ['greeting'], + outputs: { version: { value: '${{ steps.read.outputs.version }}' } }, + runs: { steps: [{ id: 'read', run: 'set-output version "1.0.0"' }] }, + }; + const parsed = LocalFunctionConfigZ.parse(config); + expect(parsed).toEqual(config); + expect(isLegacyFunctionConfig(parsed)).toBe(false); + }); + + it('parses a command function', () => { + const config = { name: 'Say hi', inputs: ['name'], command: 'echo hi' }; + const parsed = LocalFunctionConfigZ.parse(config); + expect(parsed).toEqual(config); + expect(isLegacyFunctionConfig(parsed)).toBe(true); + }); + + it('parses a path function', () => { + const parsed = LocalFunctionConfigZ.parse({ path: './my-function' }); + expect(parsed).toEqual({ path: './my-function' }); + expect(isLegacyFunctionConfig(parsed)).toBe(true); + }); + + it('narrows a legacy config on the path field', () => { + const parsed = LocalFunctionConfigZ.parse({ path: './my-function' }); + expect(isLegacyFunctionConfig(parsed)).toBe(true); + if (isLegacyFunctionConfig(parsed) && parsed.path !== undefined) { + const modulePath: string = parsed.path; + expect(modulePath).toBe('./my-function'); + } + }); + + it.each<[string, unknown]>([ + ['unknown top-level keys on a command function', { command: 'echo hi', runz: {} }], + ['unknown top-level keys on a path function', { path: './fn', run: 'echo' }], + ['unknown supported platforms', { command: 'echo hi', supported_platforms: ['windows'] }], + [ + 'the composite output shape on a single-step function', + { command: 'echo hi', outputs: { version: { value: '1.0.0' } } }, + ], + [ + 'a config mixing runs.steps with command', + { command: 'echo hi', runs: { steps: [{ run: 'echo hello' }] } }, + ], + [ + 'a config mixing runs.steps with path', + { path: './my-function', runs: { steps: [{ run: 'echo hello' }] } }, + ], + ['a config declaring both command and path', { command: 'echo hi', path: './fn' }], + ['a config declaring none of runs.steps, command and path', { name: 'Nothing' }], + ['an empty mapping', {}], + ['a string in place of a mapping', 'command: echo hi'], + ['an array in place of a mapping', [{ command: 'echo hi' }]], + ['null in place of a mapping', null], + ])('rejects %s with the generic union message', (_description, config) => { + expect(parseErrorMessages(LocalFunctionConfigZ, config)).toEqual([UNION_ERROR_MESSAGE]); + }); + + it('collapses branch errors into the union message in formatted output', () => { + const result = LocalFunctionConfigZ.safeParse({ command: 42 }); + expect(result.success).toBe(false); + expect(z.prettifyError(result.error!)).toBe(`✖ ${UNION_ERROR_MESSAGE}`); + }); + + it('surfaces the min-steps issue of the composite branch with its field path', () => { + const result = LocalFunctionConfigZ.safeParse({ runs: { steps: [] } }); + expect(result.success).toBe(false); + expect(z.prettifyError(result.error!)).toMatch( + /must declare at least one step under "runs.steps"\.\n {2}→ at runs.steps/ + ); + }); +}); diff --git a/packages/eas-build-job/src/compositeFunction.ts b/packages/eas-build-job/src/compositeFunction.ts index 6e2eda2bbb..2ddddb7302 100644 --- a/packages/eas-build-job/src/compositeFunction.ts +++ b/packages/eas-build-job/src/compositeFunction.ts @@ -1,10 +1,6 @@ /** - * Schema for local composite functions, reusable step groups referenced via `uses:` in EAS - * workflows (`.eas/workflows/*.yml`) or inline job step definitions. - * - * This module defines the shape of a composite function configuration file (`function.yml`). - * Callers that load composite function files format validation errors from `CompositeFunctionConfigZ`. - * Local composite functions are not supported in `.eas/build/*.yml` custom build config files. + * Schema for the composite shape of a local function: a reusable group of steps declared under + * `runs.steps` in a `function.yml` file. One branch of `LocalFunctionConfigZ` in `./localFunction`. */ import { z } from 'zod'; @@ -12,6 +8,10 @@ import { StepZ } from './step'; const CompositeFunctionInputValueTypeNameZ = z.enum(['string', 'boolean', 'number', 'json']); +export type CompositeFunctionInputValueTypeName = z.infer< + typeof CompositeFunctionInputValueTypeNameZ +>; + const CompositeFunctionInputValueZ = z.union([ z.string(), z.boolean(), @@ -20,7 +20,7 @@ const CompositeFunctionInputValueZ = z.union([ z.record(z.string(), z.unknown()), ]); -const CompositeFunctionInputZ = z.union([ +export const CompositeFunctionInputZ = z.union([ z .string() .describe('Shorthand for an input name with default type "string" and no default value.'), @@ -91,6 +91,10 @@ export const CompositeFunctionConfigZ = z }) .describe('Steps executed when the composite function is invoked.'), }), + command: z.never().optional(), + path: z.never().optional(), + shell: z.never().optional(), + supported_platforms: z.never().optional(), }) .strict(); @@ -110,5 +114,3 @@ export const CompositeFunctionConfigZ = z * run: set-output version "1.0.0" */ export type CompositeFunctionConfig = z.infer; - -export type CompositeFunctionCatalog = Record; diff --git a/packages/eas-build-job/src/index.ts b/packages/eas-build-job/src/index.ts index 0bd20cd4cf..f4f607103e 100644 --- a/packages/eas-build-job/src/index.ts +++ b/packages/eas-build-job/src/index.ts @@ -28,6 +28,8 @@ export * from './generic'; export * from './hooks'; export * from './step'; export * from './compositeFunction'; +export * from './legacyFunction'; +export * from './localFunction'; export * from './submission-config'; export * from './projectPackage'; export * from './deviceRunSession'; diff --git a/packages/eas-build-job/src/legacyFunction.ts b/packages/eas-build-job/src/legacyFunction.ts new file mode 100644 index 0000000000..88a798ed7e --- /dev/null +++ b/packages/eas-build-job/src/legacyFunction.ts @@ -0,0 +1,95 @@ +/** + * Legacy single-step local function shape: shell `command` or JS module `path` in `function.yml`. + * Branches of `LocalFunctionConfigZ` in `./localFunction`. + */ +import { z } from 'zod'; + +import { CompositeFunctionInputZ } from './compositeFunction'; + +const LegacyFunctionOutputZ = z.union([ + z.string().describe('Shorthand for a required output name.'), + z + .object({ + name: z.string(), + required: z.boolean().optional(), + }) + .strict(), +]); + +const LegacyFunctionPlatformZ = z.enum(['darwin', 'linux']); + +const LegacyFunctionBaseZ = z.object({ + /** + * @example + * name: Say hi + */ + name: z.string().optional().describe('Display name of the function.'), + /** + * @example + * inputs: + * - greeting + * - name: platform + * type: string + * default_value: ios + */ + inputs: z + .array(CompositeFunctionInputZ) + .optional() + .describe( + 'Inputs accepted by the function. Each input is required unless it sets `required: false`.' + ), + /** + * @example + * outputs: + * - name: version + * - name: sha + * required: false + */ + outputs: z + .array(LegacyFunctionOutputZ) + .optional() + .describe( + 'Outputs the function sets. Each output is required unless it sets `required: false`.' + ), + shell: z.string().optional().describe('Shell to run the function with.'), + supported_platforms: z + .array(LegacyFunctionPlatformZ) + .optional() + .describe('Runtime platforms the function can run on.'), + runs: z.never().optional(), +}); + +export const LegacyCommandFunctionConfigZ = LegacyFunctionBaseZ.extend({ + /** + * @example + * command: echo "Hi, ${ inputs.name }!" + */ + command: z.string().describe('Shell script executed when the function is invoked.'), + path: z.never().optional(), +}).strict(); + +export const LegacyPathFunctionConfigZ = LegacyFunctionBaseZ.extend({ + /** + * @example + * path: ./my-function + */ + path: z + .string() + .describe( + 'Directory with a prebuilt JavaScript module and its package.json, resolved relative to the function file.' + ), + command: z.never().optional(), +}).strict(); + +/** + * Structure of a single-step local function configuration file (`function.yml`). + * + * @example + * name: Say hi + * inputs: + * - name + * command: echo "Hi, ${ inputs.name }!" + */ +export type LegacyFunctionConfig = + | z.infer + | z.infer; diff --git a/packages/eas-build-job/src/localFunction.ts b/packages/eas-build-job/src/localFunction.ts new file mode 100644 index 0000000000..d5586ec6a3 --- /dev/null +++ b/packages/eas-build-job/src/localFunction.ts @@ -0,0 +1,40 @@ +/** + * Schema for local functions, reusable units of work referenced via `uses:` in EAS workflows + * (`.eas/workflows/*.yml`) or inline job step definitions. + * + * A local function configuration file (`function.yml`) declares either a composite function (a + * group of steps under `runs.steps`, see `./compositeFunction`) or a single-step function + * carrying a shell `command` or a `path` to a prebuilt JavaScript module (see `./legacyFunction`). + * This module unions the two shapes. + * + * Callers that load local function files format validation errors from `LocalFunctionConfigZ`. + * Any invalid file reports the single generic union message; parse a branch schema directly for + * field-level errors. `function.yml` files cannot be referenced from `.eas/build/*.yml` custom + * build configs. + */ +import { z } from 'zod'; + +import { CompositeFunctionConfig, CompositeFunctionConfigZ } from './compositeFunction'; +import { + LegacyCommandFunctionConfigZ, + LegacyFunctionConfig, + LegacyPathFunctionConfigZ, +} from './legacyFunction'; + +export const LocalFunctionConfigZ = z.union( + [CompositeFunctionConfigZ, LegacyCommandFunctionConfigZ, LegacyPathFunctionConfigZ], + { + error: + 'A local function must declare exactly one of "runs.steps" (a composite function), "command" (a shell script) or "path" (a JavaScript function module), and its fields must match that shape.', + } +); + +export type LocalFunctionConfig = CompositeFunctionConfig | LegacyFunctionConfig; + +export function isLegacyFunctionConfig( + config: LocalFunctionConfig +): config is LegacyFunctionConfig { + return config.runs === undefined; +} + +export type LocalFunctionCatalog = Record; diff --git a/packages/steps/src/CompositeFunctionExpander.ts b/packages/steps/src/CompositeFunctionExpander.ts index 87f91ca42d..f20e743dc8 100644 --- a/packages/steps/src/CompositeFunctionExpander.ts +++ b/packages/steps/src/CompositeFunctionExpander.ts @@ -8,11 +8,12 @@ * resolve against composite-function-local names. */ import { - CompositeFunctionCatalog, CompositeFunctionConfig, FunctionStep, + LocalFunctionCatalog, ShellStep, Step, + isLegacyFunctionConfig, isStepFunctionStep, isStepShellStep, } from '@expo/eas-build-job'; @@ -66,7 +67,7 @@ type StepOverrides = { export class CompositeFunctionExpander { constructor( private readonly ctx: BuildStepGlobalContext, - private readonly compositeFunctionCatalog: CompositeFunctionCatalog, + private readonly compositeFunctionCatalog: LocalFunctionCatalog, private readonly functionMaps: FunctionMaps ) {} @@ -191,6 +192,11 @@ export class CompositeFunctionExpander { `Local composite function "${compositeFunctionPath}" does not exist. Expected a "function.yml" (or "function.yaml") file at "${compositeFunctionPath}" relative to the EAS project root (convention: ".eas/functions/").` ); } + if (isLegacyFunctionConfig(compositeFunction)) { + throw new BuildConfigError( + `Local function "${compositeFunctionPath}" uses the legacy command/path shape, which this expander does not support yet.` + ); + } return compositeFunction; } diff --git a/packages/steps/src/StepsConfigParser.ts b/packages/steps/src/StepsConfigParser.ts index 5db63bacfc..3986870558 100644 --- a/packages/steps/src/StepsConfigParser.ts +++ b/packages/steps/src/StepsConfigParser.ts @@ -1,10 +1,10 @@ import { - CompositeFunctionCatalog, CompositeFunctionConfig, FunctionStep, HookAnchorId, HookKey, Hooks, + LocalFunctionCatalog, Step, isHookAnchorId, isStepFunctionStep, @@ -44,7 +44,7 @@ export class StepsConfigParser extends AbstractConfigParser { private readonly steps: Step[]; private readonly hooks: Hooks; /** Pre-loaded composite function configs keyed by normalized path (e.g. `./.eas/functions/setup`). */ - private readonly compositeFunctionCatalog: CompositeFunctionCatalog; + private readonly compositeFunctionCatalog: LocalFunctionCatalog; private readonly loadCompositeFunction?: ( compositeFunctionPath: string ) => Promise; @@ -65,7 +65,7 @@ export class StepsConfigParser extends AbstractConfigParser { hooks: Hooks | undefined; externalFunctions?: BuildFunction[]; externalFunctionGroups?: BuildFunctionGroup[]; - compositeFunctionCatalog?: CompositeFunctionCatalog; + compositeFunctionCatalog?: LocalFunctionCatalog; /** Loads a hook composite missing from the catalog. When omitted, missing entries fail as unknown. */ loadCompositeFunction?: (compositeFunctionPath: string) => Promise; } diff --git a/packages/steps/src/__tests__/StepsConfigParser-composite-functions-test-utils.ts b/packages/steps/src/__tests__/StepsConfigParser-composite-functions-test-utils.ts index 185139f049..572b3ec733 100644 --- a/packages/steps/src/__tests__/StepsConfigParser-composite-functions-test-utils.ts +++ b/packages/steps/src/__tests__/StepsConfigParser-composite-functions-test-utils.ts @@ -1,4 +1,4 @@ -import { CompositeFunctionCatalog, CompositeFunctionConfigZ, Step } from '@expo/eas-build-job'; +import { CompositeFunctionConfigZ, LocalFunctionCatalog, Step } from '@expo/eas-build-job'; import { createGlobalContextMock } from './utils/context'; import { BuildFunction } from '../BuildFunction'; @@ -10,8 +10,8 @@ import { StepsConfigParser } from '../StepsConfigParser'; export const SETUP = './.eas/functions/setup'; -export function makeCatalog(entries: Record): CompositeFunctionCatalog { - const catalog: CompositeFunctionCatalog = {}; +export function makeCatalog(entries: Record): LocalFunctionCatalog { + const catalog: LocalFunctionCatalog = {}; for (const [compositeFunctionPath, raw] of Object.entries(entries)) { catalog[compositeFunctionPath] = CompositeFunctionConfigZ.parse(raw); } diff --git a/packages/steps/src/__tests__/StepsConfigParser-hooks-test.ts b/packages/steps/src/__tests__/StepsConfigParser-hooks-test.ts index 0bb784ce00..1887103fcb 100644 --- a/packages/steps/src/__tests__/StepsConfigParser-hooks-test.ts +++ b/packages/steps/src/__tests__/StepsConfigParser-hooks-test.ts @@ -1,8 +1,8 @@ import { - CompositeFunctionCatalog, CompositeFunctionConfig, CompositeFunctionConfigZ, Hooks, + LocalFunctionCatalog, Step, } from '@expo/eas-build-job'; @@ -53,7 +53,7 @@ async function parseWorkflowAsync({ hooks: Hooks | undefined; externalFunctions?: BuildFunction[]; externalFunctionGroups?: BuildFunctionGroup[]; - compositeFunctionCatalog?: CompositeFunctionCatalog; + compositeFunctionCatalog?: LocalFunctionCatalog; loadCompositeFunction?: (compositeFunctionPath: string) => Promise; }): Promise { const parser = new StepsConfigParser(ctx, { diff --git a/packages/steps/src/hooks.ts b/packages/steps/src/hooks.ts index f527b885fd..8aebb0ca85 100644 --- a/packages/steps/src/hooks.ts +++ b/packages/steps/src/hooks.ts @@ -1,6 +1,6 @@ import { - CompositeFunctionCatalog, HookAnchorId, + LocalFunctionCatalog, ShellStep, Step, isStepFunctionStep, @@ -78,7 +78,7 @@ export async function constructHookEntriesAsync( externalFunctions?: BuildFunction[]; externalFunctionGroups?: BuildFunctionGroup[]; /** When omitted, composite `uses:` fail as missing from an empty catalog. */ - compositeFunctionCatalog?: CompositeFunctionCatalog; + compositeFunctionCatalog?: LocalFunctionCatalog; } ): Promise { // An empty array is a valid no-op (e.g. opting out of a default hook); diff --git a/packages/steps/src/utils/localCompositeFunctions.ts b/packages/steps/src/utils/localCompositeFunctions.ts index 58c4673351..1ce40e718f 100644 --- a/packages/steps/src/utils/localCompositeFunctions.ts +++ b/packages/steps/src/utils/localCompositeFunctions.ts @@ -1,7 +1,7 @@ import { - CompositeFunctionCatalog, CompositeFunctionConfig, CompositeFunctionConfigZ, + LocalFunctionCatalog, Step, } from '@expo/eas-build-job'; import fs from 'fs/promises'; @@ -57,8 +57,8 @@ export async function buildCompositeFunctionCatalogFromStepsAsync({ }: { rootSteps: readonly Step[]; loadCompositeFunction: (compositeFunctionPath: string) => Promise; -}): Promise { - const catalog: CompositeFunctionCatalog = {}; +}): Promise { + const catalog: LocalFunctionCatalog = {}; await extendCompositeFunctionCatalogFromStepsAsync({ catalog, rootSteps, loadCompositeFunction }); return catalog; } @@ -69,7 +69,7 @@ export async function extendCompositeFunctionCatalogFromStepsAsync({ rootSteps, loadCompositeFunction, }: { - catalog: CompositeFunctionCatalog; + catalog: LocalFunctionCatalog; rootSteps: readonly Step[]; loadCompositeFunction: (compositeFunctionPath: string) => Promise; }): Promise { @@ -173,7 +173,7 @@ export function createLocalCompositeFunctionLoader( export async function buildLocalCompositeFunctionCatalogAsync( projectRoot: string, { rootSteps, logger }: { rootSteps: readonly Step[]; logger?: LocalCompositeFunctionLogger } -): Promise { +): Promise { return await buildCompositeFunctionCatalogFromStepsAsync({ rootSteps, loadCompositeFunction: createLocalCompositeFunctionLoader(projectRoot, { logger }),