From 3b62a925cc84c023f419ed2621bd99ba9373bb02 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Thu, 30 Jul 2026 21:08:20 +0200 Subject: [PATCH 1/6] Do not resolve params of irrelevant stages --- .../defer-irrelevant-stage-params.js | 44 ++++++ lib/configuration/variables/resolve.js | 14 +- scripts/serverless.js | 20 +++ .../defer-irrelevant-stage-params.test.js | 93 ++++++++++++ .../configuration/variables/resolve.test.js | 67 +++++++++ test/unit/scripts/serverless.test.js | 134 ++++++++++++++++++ 6 files changed, 369 insertions(+), 3 deletions(-) create mode 100644 lib/configuration/variables/defer-irrelevant-stage-params.js create mode 100644 test/unit/lib/configuration/variables/defer-irrelevant-stage-params.test.js diff --git a/lib/configuration/variables/defer-irrelevant-stage-params.js b/lib/configuration/variables/defer-irrelevant-stage-params.js new file mode 100644 index 0000000000..b6da7311de --- /dev/null +++ b/lib/configuration/variables/defer-irrelevant-stage-params.js @@ -0,0 +1,44 @@ +// Builds an `isPropertyDeferred` predicate for the variables resolver, which prevents eager +// resolution of `params.` sections that do not concern the effective stage. +// Such sections are never read by the "param" variable source (which only consults +// `params.` and `params.default`), so resolving them upfront at best wastes +// remote calls (e.g. SSM) and at worst fails the command on errors that concern other stages. +// +// Deferred properties stay in `variablesMeta`: an explicit reference (e.g. +// `${self:params.prod.x}`) still resolves them on demand, exactly as before. Entries that are +// still deferred after the final resolution pass are dropped from `variablesMeta` by the caller +// (see `scripts/serverless.js`), leaving their raw (unresolved) values in the configuration. +// +// Known limitation: when the whole `params` section resolves from a single variable +// (e.g. `params: ${file(./params.yml)}`), its content is only discovered mid-pass and is +// resolved eagerly as before. + +'use strict'; + +const isPlainObject = require('type/plain-object/is'); +const { hasOwn } = require('../../utils/safe-object'); + +// `configuration` and `resolverConfiguration` are read live at each call (not snapshotted): +// plugins may extend the configuration with new stages, and the effective CLI options object +// is replaced between resolution passes +module.exports = (configuration, resolverConfiguration) => (propertyPath) => { + if (!propertyPath.startsWith('params\0')) return false; + if (!isPlainObject(configuration.params)) return false; + + const stageKey = propertyPath.split('\0')[1]; + if (stageKey === 'default') return false; + + // Mirrors effective stage resolution of the "param" source + // (lib/configuration/variables/sources/instance-dependent/param.js) + const options = resolverConfiguration.options || {}; + let stage = hasOwn(options, 'stage') && options.stage != null ? options.stage : null; + if (stage == null && isPlainObject(configuration.provider)) { + const configuredStage = configuration.provider.stage; + if (typeof configuredStage === 'string' || typeof configuredStage === 'number') { + stage = configuredStage; + } + } + if (stage == null) stage = 'dev'; + + return stageKey !== String(stage); +}; diff --git a/lib/configuration/variables/resolve.js b/lib/configuration/variables/resolve.js index 5fedb94875..bd37add0b7 100644 --- a/lib/configuration/variables/resolve.js +++ b/lib/configuration/variables/resolve.js @@ -78,6 +78,7 @@ class VariablesResolver { options, fulfilledSources, propertyPathsToResolve, + isPropertyDeferred, }) { this.serviceDir = serviceDir; this.configuration = configuration; @@ -85,6 +86,10 @@ class VariablesResolver { this.sources = sources; this.options = options; this.fulfilledSources = fulfilledSources; + // Matching properties are not scheduled for resolution in this pass. + // They remain in `variablesMeta`, so they still resolve on demand when another property + // depends on them (see `resolveDependentProperty`) + this.isPropertyDeferred = isPropertyDeferred || (() => false); this.propertyDependenciesMap = new Map(); this.propertyResolutionNestDepthMap = new Map(); @@ -99,6 +104,7 @@ class VariablesResolver { Array.from(propertyPathsToResolve, (propertyPathToResolve) => Promise.all( Array.from(variablesMeta.keys(), (propertyPath) => { + if (this.isPropertyDeferred(propertyPath)) return null; if ( propertyPathToResolve.startsWith(`${propertyPath}\0`) || propertyPath === propertyPathToResolve || @@ -114,9 +120,10 @@ class VariablesResolver { } return Promise.all( - Array.from(variablesMeta.keys(), (propertyPath) => - this.resolveProperty(resolutionBatchId, propertyPath) - ) + Array.from(variablesMeta.keys(), (propertyPath) => { + if (this.isPropertyDeferred(propertyPath)) return null; + return this.resolveProperty(resolutionBatchId, propertyPath); + }) ).then(() => {}); } async resolveVariables(resolutionBatchId, propertyPath, valueMeta) { @@ -697,6 +704,7 @@ module.exports = async (data) => { for (const { resolve } of Object.values(data.sources)) ensurePlainFunction(resolve); ensurePlainObject(data.options); ensureSet(data.fulfilledSources); + ensurePlainFunction(data.isPropertyDeferred, { isOptional: true }); ensureSet(data.propertyPathsToResolve, { isOptional: true }); if (data.propertyPathsToResolve) { data.propertyPathsToResolve = new Set(Array.from(data.propertyPathsToResolve, ensureString)); diff --git a/scripts/serverless.js b/scripts/serverless.js index 2cd9166535..6c5ffa634d 100755 --- a/scripts/serverless.js +++ b/scripts/serverless.js @@ -138,6 +138,7 @@ process.once('uncaughtException', (error) => { const Serverless = require('../lib/serverless'); const { safeShallowAssign } = require('../lib/utils/safe-object'); const resolveVariables = require('../lib/configuration/variables/resolve'); + const deferIrrelevantStageParams = require('../lib/configuration/variables/defer-irrelevant-stage-params'); const isPropertyResolved = require('../lib/configuration/variables/is-property-resolved'); const eventuallyReportVariableResolutionErrors = require('../lib/configuration/variables/eventually-report-resolution-errors'); const filterSupportedOptions = require('../lib/cli/filter-supported-options'); @@ -273,6 +274,10 @@ process.once('uncaughtException', (error) => { fulfilledSources: new Set(['file', 'self', 'strToBool']), propertyPathsToResolve: new Set(['provider\0name', 'provider\0stage', 'useDotenv']), }; + resolverConfiguration.isPropertyDeferred = deferIrrelevantStageParams( + configuration, + resolverConfiguration + ); await resolveVariables(resolverConfiguration); @@ -571,6 +576,10 @@ process.once('uncaughtException', (error) => { ? new Set(['plugins', 'provider\0name', 'provider\0stage', 'useDotenv']) : null, }; + resolverConfiguration.isPropertyDeferred = deferIrrelevantStageParams( + configuration, + resolverConfiguration + ); } if (commandSchema) { @@ -644,6 +653,17 @@ process.once('uncaughtException', (error) => { // Having all source resolvers configured, resolve variables processLog.debug('resolve all variables'); await resolveVariables(resolverConfiguration); + + // Drop entries which were deliberately left unresolved (params of irrelevant stages), + // so they're not reported as resolution errors or unrecognized sources. + // Their raw values remain in the configuration. + // Note: this must not run while a resolution pass is in progress + for (const propertyPath of Array.from(variablesMeta.keys())) { + if (resolverConfiguration.isPropertyDeferred(propertyPath)) { + variablesMeta.delete(propertyPath); + } + } + if (!variablesMeta.size) return; if ( eventuallyReportVariableResolutionErrorsForCliInput( diff --git a/test/unit/lib/configuration/variables/defer-irrelevant-stage-params.test.js b/test/unit/lib/configuration/variables/defer-irrelevant-stage-params.test.js new file mode 100644 index 0000000000..32f2059382 --- /dev/null +++ b/test/unit/lib/configuration/variables/defer-irrelevant-stage-params.test.js @@ -0,0 +1,93 @@ +'use strict'; + +const { expect } = require('chai'); + +const deferIrrelevantStageParams = require('../../../../../lib/configuration/variables/defer-irrelevant-stage-params'); + +const p = (...keys) => keys.join('\0'); + +describe('test/unit/lib/configuration/variables/defer-irrelevant-stage-params.test.js', () => { + const buildPredicate = ({ + params = { dev: {}, prod: {}, default: {} }, + provider, + options, + } = {}) => deferIrrelevantStageParams({ params, provider }, { options }); + + it('should defer params of not effective stages', () => { + const isPropertyDeferred = buildPredicate({ options: { stage: 'dev' } }); + expect(isPropertyDeferred(p('params', 'prod', 'secret'))).to.be.true; + expect(isPropertyDeferred(p('params', 'prod'))).to.be.true; + expect(isPropertyDeferred(p('params', 'prod', 'nested', 'deep'))).to.be.true; + }); + + it('should not defer params of the effective stage', () => { + const isPropertyDeferred = buildPredicate({ options: { stage: 'prod' } }); + expect(isPropertyDeferred(p('params', 'prod', 'secret'))).to.be.false; + }); + + it('should not defer default params', () => { + const isPropertyDeferred = buildPredicate({ options: { stage: 'dev' } }); + expect(isPropertyDeferred(p('params', 'default', 'secret'))).to.be.false; + expect(isPropertyDeferred(p('params', 'default'))).to.be.false; + }); + + it('should not defer properties outside of "params"', () => { + const isPropertyDeferred = buildPredicate({ options: { stage: 'dev' } }); + expect(isPropertyDeferred(p('provider', 'stage'))).to.be.false; + expect(isPropertyDeferred(p('paramsLike', 'prod'))).to.be.false; + expect(isPropertyDeferred('params')).to.be.false; + }); + + it('should resolve effective stage from "provider.stage" when not passed via options', () => { + const isPropertyDeferred = buildPredicate({ provider: { stage: 'prod' }, options: {} }); + expect(isPropertyDeferred(p('params', 'prod', 'secret'))).to.be.false; + expect(isPropertyDeferred(p('params', 'dev', 'secret'))).to.be.true; + }); + + it('should prioritize stage passed via options over "provider.stage"', () => { + const isPropertyDeferred = buildPredicate({ + provider: { stage: 'prod' }, + options: { stage: 'dev' }, + }); + expect(isPropertyDeferred(p('params', 'prod', 'secret'))).to.be.true; + expect(isPropertyDeferred(p('params', 'dev', 'secret'))).to.be.false; + }); + + it('should default effective stage to "dev"', () => { + const isPropertyDeferred = buildPredicate({ options: {} }); + expect(isPropertyDeferred(p('params', 'dev', 'secret'))).to.be.false; + expect(isPropertyDeferred(p('params', 'prod', 'secret'))).to.be.true; + }); + + it('should ignore unresolved "provider.stage" values safely', () => { + const isPropertyDeferred = buildPredicate({ + provider: { stage: { unexpected: 'object' } }, + options: {}, + }); + expect(isPropertyDeferred(p('params', 'dev', 'secret'))).to.be.false; + expect(isPropertyDeferred(p('params', 'prod', 'secret'))).to.be.true; + }); + + it('should not defer when "params" is not a plain object', () => { + const isPropertyDeferred = buildPredicate({ params: null, options: { stage: 'dev' } }); + expect(isPropertyDeferred(p('params', 'prod', 'secret'))).to.be.false; + const isPropertyDeferredForArray = buildPredicate({ + params: ['unexpected'], + options: { stage: 'dev' }, + }); + expect(isPropertyDeferredForArray(p('params', '0', 'secret'))).to.be.false; + }); + + it('should read configuration and options live', () => { + const configuration = { params: { dev: {}, prod: {} } }; + const resolverConfiguration = { options: {} }; + const isPropertyDeferred = deferIrrelevantStageParams(configuration, resolverConfiguration); + expect(isPropertyDeferred(p('params', 'prod', 'secret'))).to.be.true; + // Options object is replaced between resolution passes + resolverConfiguration.options = { stage: 'prod' }; + expect(isPropertyDeferred(p('params', 'prod', 'secret'))).to.be.false; + // Plugins may replace the "params" section during initialization + configuration.params = 'not-an-object'; + expect(isPropertyDeferred(p('params', 'prod', 'secret'))).to.be.false; + }); +}); diff --git a/test/unit/lib/configuration/variables/resolve.test.js b/test/unit/lib/configuration/variables/resolve.test.js index 758c2fdf41..2970de7708 100644 --- a/test/unit/lib/configuration/variables/resolve.test.js +++ b/test/unit/lib/configuration/variables/resolve.test.js @@ -676,4 +676,71 @@ describe('test/unit/lib/configuration/variables/resolve.test.js', () => { expect(valueMeta).to.have.property('variables'); }); }); + + describe('Deferred resolution ("isPropertyDeferred")', () => { + const configuration = { + params: { + dev: { greeting: '${sourceDirect:}' }, + prod: { + referenced: '${sourceCounted:}', + nested: { deep: '${sourceCounted:}' }, + }, + staging: '${sourceCounted:}', + }, + referencesDeferred: '${sourceProperty(params, prod, referenced)}', + }; + let variablesMeta; + let callCount = 0; + const sources = { + sourceDirect: { + resolve: () => ({ value: 'hello' }), + }, + sourceCounted: { + resolve: () => { + ++callCount; + return { value: `counted:${callCount}` }; + }, + }, + sourceProperty: { + resolve: async ({ params, resolveConfigurationProperty }) => { + const result = await resolveConfigurationProperty(params || []); + return { value: result == null ? null : result }; + }, + }, + }; + before(async () => { + variablesMeta = resolveMeta(configuration); + await resolve({ + serviceDir: process.cwd(), + configuration, + variablesMeta, + sources, + options: {}, + fulfilledSources: new Set(['sourceDirect', 'sourceCounted', 'sourceProperty']), + isPropertyDeferred: (propertyPath) => { + if (!propertyPath.startsWith('params\0')) return false; + const stageKey = propertyPath.split('\0')[1]; + return stageKey !== 'dev' && stageKey !== 'default'; + }, + }); + }); + + it('should resolve not deferred properties', () => { + expect(configuration.params.dev.greeting).to.equal('hello'); + }); + it('should not resolve deferred properties', () => { + expect(configuration.params.prod.nested.deep).to.equal('${sourceCounted:}'); + expect(configuration.params.staging).to.equal('${sourceCounted:}'); + expect(variablesMeta.get('params\0prod\0nested\0deep')).to.have.property('variables'); + expect(variablesMeta.get('params\0staging')).to.have.property('variables'); + }); + it('should resolve deferred properties on which other properties depend', () => { + expect(configuration.params.prod.referenced).to.equal('counted:1'); + expect(configuration.referencesDeferred).to.equal('counted:1'); + expect(variablesMeta.has('params\0prod\0referenced')).to.be.false; + }); + it('should not invoke sources of deferred properties', () => { + expect(callCount).to.equal(1); + }); + }); }); diff --git a/test/unit/scripts/serverless.test.js b/test/unit/scripts/serverless.test.js index 8ab71e7b07..9b4d003315 100644 --- a/test/unit/scripts/serverless.test.js +++ b/test/unit/scripts/serverless.test.js @@ -384,4 +384,138 @@ describe('test/unit/scripts/serverless.test.js', () => { expect(String(error.stdoutBuffer)).to.include('command "config credentials" requires'); } }); + + describe('params of irrelevant stages', () => { + const print = async (serviceDir, args = []) => + stripAnsi( + String( + (await spawn('node', [serverlessPath, 'print', ...args], { cwd: serviceDir })) + .stdoutBuffer + ) + ); + + it('should not resolve params of other stages', async () => { + const { servicePath: serviceDir } = await setupProgrammaticFixture('aws', { + configExt: { + params: { + dev: { greeting: 'hello-dev' }, + prod: { + fromUnknownSource: '${unknownSource:foo}', + fromMissingEnv: '${env:OSLS_TEST_SURELY_MISSING_ENV_VAR}', + }, + }, + custom: { greeting: '${param:greeting}' }, + }, + }); + const output = await print(serviceDir); + expect(output).to.include('greeting: hello-dev'); + // Values of other stages are left as-is + expect(output).to.include('${unknownSource:foo}'); + expect(output).to.include('${env:OSLS_TEST_SURELY_MISSING_ENV_VAR}'); + }); + + it('should still resolve params of their own stage', async () => { + const { servicePath: serviceDir } = await setupProgrammaticFixture('aws', { + configExt: { + params: { + dev: { greeting: 'hello-dev' }, + prod: { fromMissingEnv: '${env:OSLS_TEST_SURELY_MISSING_ENV_VAR}' }, + }, + }, + }); + try { + await print(serviceDir, ['--stage', 'prod']); + throw new Error('Unexpected'); + } catch (error) { + expect(error.code).to.equal(1); + expect(String(error.stdoutBuffer)).to.include('params.prod.fromMissingEnv'); + } + }); + + it('should resolve other stage params referenced with "self" variables', async () => { + const { servicePath: serviceDir } = await setupProgrammaticFixture('aws', { + configExt: { + params: { + prod: { domain: '${file(./prod-params.yml):domain}' }, + }, + custom: { prodDomain: '${self:params.prod.domain}' }, + }, + files: [{ to: 'prod-params.yml', contents: 'domain: prod.example.com' }], + }); + expect(await print(serviceDir)).to.include('prodDomain: prod.example.com'); + }); + + it('should resolve "self" references nested in unresolved other stage params', async () => { + const { servicePath: serviceDir } = await setupProgrammaticFixture('aws', { + configExt: { + params: { + prod: '${file(./prod-params.yml)}', + }, + custom: { prodApiKey: "${self:params.prod.apiKey, 'fallback-value'}" }, + }, + files: [{ to: 'prod-params.yml', contents: 'apiKey: real-prod-key' }], + }); + const output = await print(serviceDir); + expect(output).to.include('prodApiKey: real-prod-key'); + expect(output).to.not.include('fallback-value'); + }); + + it('should reject variable syntax errors in params of other stages', async () => { + const { servicePath: serviceDir } = await setupProgrammaticFixture('aws', { + configExt: { + params: { prod: { bad: '${env:UNCLOSED' } }, + }, + }); + try { + await print(serviceDir); + throw new Error('Unexpected'); + } catch (error) { + expect(error.code).to.equal(1); + expect(String(error.stdoutBuffer)).to.include('params.prod.bad'); + } + }); + + it('should resolve effective stage from "provider.stage"', async () => { + const { servicePath: serviceDir } = await setupProgrammaticFixture('aws', { + configExt: { + provider: { stage: 'prod' }, + params: { + dev: { greeting: '${unknownSource:foo}' }, + prod: { greeting: 'hello-prod' }, + }, + custom: { greeting: '${param:greeting}' }, + }, + }); + expect(await print(serviceDir)).to.include('greeting: hello-prod'); + }); + + it('should not resolve other stage params added by plugins', async () => { + const { servicePath: serviceDir } = await setupProgrammaticFixture('aws', { + configExt: { + plugins: ['./extend-params-plugin'], + params: { dev: { greeting: 'hello-dev' } }, + custom: { greeting: '${param:greeting}' }, + }, + files: [ + { + to: 'extend-params-plugin.js', + contents: [ + "'use strict';", + 'module.exports = class ExtendParamsPlugin {', + ' constructor(serverless) { this.serverless = serverless; }', + ' async asyncInit() {', + " this.serverless.extendConfiguration(['params', 'prod', 'fromPlugin'],", + " '${env:OSLS_TEST_SURELY_MISSING_ENV_VAR}');", + ' }', + '};', + '', + ].join('\n'), + }, + ], + }); + const output = await print(serviceDir); + expect(output).to.include('greeting: hello-dev'); + expect(output).to.include('${env:OSLS_TEST_SURELY_MISSING_ENV_VAR}'); + }); + }); }); From 123dc55626bcd428cf4f98c1a1630e2778fdd9c9 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Thu, 30 Jul 2026 21:36:21 +0200 Subject: [PATCH 2/6] Never defer resolution of the "params." section itself --- docs/guides/parameters.md | 16 +++++++++ .../defer-irrelevant-stage-params.js | 21 ++++++++--- lib/configuration/variables/resolve.js | 2 +- scripts/serverless.js | 3 ++ .../defer-irrelevant-stage-params.test.js | 25 +++++++++++-- test/unit/scripts/serverless.test.js | 35 ++++++++++++++++++- 6 files changed, 93 insertions(+), 9 deletions(-) diff --git a/docs/guides/parameters.md b/docs/guides/parameters.md index f94512cbb6..d7144a598b 100644 --- a/docs/guides/parameters.md +++ b/docs/guides/parameters.md @@ -68,6 +68,22 @@ Here is the priority used to resolve a `${param:XXX}` variable: This is especially useful in development when deploying to ephemeral stages (e.g. "feature-x"). The stage might not have any parameter, therefore it will default to the parameters set on the service. However, in other stages, like "prod", or "staging", you may override the service-level parameters with stage-level parameters to use values unique to that stage. +## Resolution of other stages + +Variables set in `params.` sections that do not concern the current stage are not resolved. For example when deploying to `dev`, a `${ssm:/prod/secret}` variable set in `params.prod` is not fetched from SSM, and errors it may raise (missing permissions, missing environment variable, unknown variable source…) do not fail the command. + +Such variables are still resolved when they are explicitly referenced, e.g. with `${self:params.prod.domain}`. + +One consequence is that `serverless print` displays these values unresolved, as they appear in `serverless.yml`: + +```yaml +params: + dev: + domain: dev.myapp.com + prod: + domain: ${ssm:/myapp/prod/domain} # left as-is when deploying to "dev" +``` + ## See also - [Referencing parameters](./variables.md#referencing-parameters) in the Variables guide for more on the `${param:XXX}` source. diff --git a/lib/configuration/variables/defer-irrelevant-stage-params.js b/lib/configuration/variables/defer-irrelevant-stage-params.js index b6da7311de..61e40a5c79 100644 --- a/lib/configuration/variables/defer-irrelevant-stage-params.js +++ b/lib/configuration/variables/defer-irrelevant-stage-params.js @@ -9,9 +9,10 @@ // still deferred after the final resolution pass are dropped from `variablesMeta` by the caller // (see `scripts/serverless.js`), leaving their raw (unresolved) values in the configuration. // -// Known limitation: when the whole `params` section resolves from a single variable -// (e.g. `params: ${file(./params.yml)}`), its content is only discovered mid-pass and is -// resolved eagerly as before. +// Known limitation: when a whole section resolves from a single variable +// (e.g. `params: ${file(./params.yml)}` or `params.prod: ${file(./prod-params.yml)}`), it is +// resolved eagerly as before. Such a section cannot be left unresolved: the configuration schema +// requires `params.` to be an object, and a raw variable string would fail validation. 'use strict'; @@ -25,7 +26,12 @@ module.exports = (configuration, resolverConfiguration) => (propertyPath) => { if (!propertyPath.startsWith('params\0')) return false; if (!isPlainObject(configuration.params)) return false; - const stageKey = propertyPath.split('\0')[1]; + const propertyPathKeys = propertyPath.split('\0'); + // Never defer the `params.` section itself: it has to resolve for the configuration + // to validate (see "Known limitation" above) + if (propertyPathKeys.length < 3) return false; + + const stageKey = propertyPathKeys[1]; if (stageKey === 'default') return false; // Mirrors effective stage resolution of the "param" source @@ -39,6 +45,11 @@ module.exports = (configuration, resolverConfiguration) => (propertyPath) => { } } if (stage == null) stage = 'dev'; + stage = String(stage); + // The effective stage is not known yet (`provider.stage` is configured behind a variable which + // did not resolve yet). Deferring against it would be resolved against a different stage in a + // later pass, which would strand params of the effective stage as never scheduled for resolution + if (stage.includes('${')) return false; - return stageKey !== String(stage); + return stageKey !== stage; }; diff --git a/lib/configuration/variables/resolve.js b/lib/configuration/variables/resolve.js index bd37add0b7..58ccf28e5d 100644 --- a/lib/configuration/variables/resolve.js +++ b/lib/configuration/variables/resolve.js @@ -104,12 +104,12 @@ class VariablesResolver { Array.from(propertyPathsToResolve, (propertyPathToResolve) => Promise.all( Array.from(variablesMeta.keys(), (propertyPath) => { - if (this.isPropertyDeferred(propertyPath)) return null; if ( propertyPathToResolve.startsWith(`${propertyPath}\0`) || propertyPath === propertyPathToResolve || propertyPath.startsWith(`${propertyPathToResolve}\0`) ) { + if (this.isPropertyDeferred(propertyPath)) return null; return this.resolveProperty(resolutionBatchId, propertyPath); } return null; diff --git a/scripts/serverless.js b/scripts/serverless.js index 6c5ffa634d..15ee9bd4d6 100755 --- a/scripts/serverless.js +++ b/scripts/serverless.js @@ -274,6 +274,7 @@ process.once('uncaughtException', (error) => { fulfilledSources: new Set(['file', 'self', 'strToBool']), propertyPathsToResolve: new Set(['provider\0name', 'provider\0stage', 'useDotenv']), }; + // Assigned after the literal, as the predicate reads `resolverConfiguration` live resolverConfiguration.isPropertyDeferred = deferIrrelevantStageParams( configuration, resolverConfiguration @@ -576,6 +577,8 @@ process.once('uncaughtException', (error) => { ? new Set(['plugins', 'provider\0name', 'provider\0stage', 'useDotenv']) : null, }; + // Assigned after the literal, as the predicate reads `resolverConfiguration` live. + // When `resolverConfiguration` was already setup above, it carries its own predicate resolverConfiguration.isPropertyDeferred = deferIrrelevantStageParams( configuration, resolverConfiguration diff --git a/test/unit/lib/configuration/variables/defer-irrelevant-stage-params.test.js b/test/unit/lib/configuration/variables/defer-irrelevant-stage-params.test.js index 32f2059382..26065a3e38 100644 --- a/test/unit/lib/configuration/variables/defer-irrelevant-stage-params.test.js +++ b/test/unit/lib/configuration/variables/defer-irrelevant-stage-params.test.js @@ -16,10 +16,15 @@ describe('test/unit/lib/configuration/variables/defer-irrelevant-stage-params.te it('should defer params of not effective stages', () => { const isPropertyDeferred = buildPredicate({ options: { stage: 'dev' } }); expect(isPropertyDeferred(p('params', 'prod', 'secret'))).to.be.true; - expect(isPropertyDeferred(p('params', 'prod'))).to.be.true; expect(isPropertyDeferred(p('params', 'prod', 'nested', 'deep'))).to.be.true; }); + it('should not defer the stage section itself', () => { + // Such section has to resolve, as the configuration schema requires it to be an object + const isPropertyDeferred = buildPredicate({ options: { stage: 'dev' } }); + expect(isPropertyDeferred(p('params', 'prod'))).to.be.false; + }); + it('should not defer params of the effective stage', () => { const isPropertyDeferred = buildPredicate({ options: { stage: 'prod' } }); expect(isPropertyDeferred(p('params', 'prod', 'secret'))).to.be.false; @@ -59,7 +64,7 @@ describe('test/unit/lib/configuration/variables/defer-irrelevant-stage-params.te expect(isPropertyDeferred(p('params', 'prod', 'secret'))).to.be.true; }); - it('should ignore unresolved "provider.stage" values safely', () => { + it('should ignore non-primitive "provider.stage" values safely', () => { const isPropertyDeferred = buildPredicate({ provider: { stage: { unexpected: 'object' } }, options: {}, @@ -68,6 +73,22 @@ describe('test/unit/lib/configuration/variables/defer-irrelevant-stage-params.te expect(isPropertyDeferred(p('params', 'prod', 'secret'))).to.be.true; }); + it('should not defer when "provider.stage" did not resolve yet', () => { + // Deferring against a stage which is about to change would strand params of the effective + // stage as never scheduled for resolution + const isPropertyDeferred = buildPredicate({ + provider: { stage: "${opt:stage, 'prod'}" }, + options: {}, + }); + expect(isPropertyDeferred(p('params', 'dev', 'secret'))).to.be.false; + expect(isPropertyDeferred(p('params', 'prod', 'secret'))).to.be.false; + }); + + it('should not defer when stage passed via options did not resolve yet', () => { + const isPropertyDeferred = buildPredicate({ options: { stage: '${env:STAGE}' } }); + expect(isPropertyDeferred(p('params', 'prod', 'secret'))).to.be.false; + }); + it('should not defer when "params" is not a plain object', () => { const isPropertyDeferred = buildPredicate({ params: null, options: { stage: 'dev' } }); expect(isPropertyDeferred(p('params', 'prod', 'secret'))).to.be.false; diff --git a/test/unit/scripts/serverless.test.js b/test/unit/scripts/serverless.test.js index 9b4d003315..d879b7e157 100644 --- a/test/unit/scripts/serverless.test.js +++ b/test/unit/scripts/serverless.test.js @@ -445,7 +445,25 @@ describe('test/unit/scripts/serverless.test.js', () => { expect(await print(serviceDir)).to.include('prodDomain: prod.example.com'); }); - it('should resolve "self" references nested in unresolved other stage params', async () => { + it('should resolve other stage sections configured with a single variable', async () => { + // Such section cannot be left unresolved, as the configuration schema requires + // "params." to be an object + const { servicePath: serviceDir } = await setupProgrammaticFixture('aws', { + configExt: { + params: { + dev: { greeting: 'hello-dev' }, + prod: '${file(./prod-params.yml)}', + }, + custom: { greeting: '${param:greeting}' }, + }, + files: [{ to: 'prod-params.yml', contents: 'apiKey: real-prod-key' }], + }); + const output = await print(serviceDir); + expect(output).to.include('greeting: hello-dev'); + expect(output).to.include('apiKey: real-prod-key'); + }); + + it('should resolve "self" references nested in other stage params', async () => { const { servicePath: serviceDir } = await setupProgrammaticFixture('aws', { configExt: { params: { @@ -460,6 +478,21 @@ describe('test/unit/scripts/serverless.test.js', () => { expect(output).to.not.include('fallback-value'); }); + it('should resolve params of the effective stage when it is configured behind a variable', async () => { + const { servicePath: serviceDir } = await setupProgrammaticFixture('aws', { + configExt: { + provider: { stage: "${opt:stage, 'prod'}" }, + params: { + dev: { greeting: '${unknownSource:foo}' }, + prod: { greeting: '${file(./prod-params.yml):greeting}' }, + }, + custom: { greeting: '${param:greeting}' }, + }, + files: [{ to: 'prod-params.yml', contents: 'greeting: hello-prod' }], + }); + expect(await print(serviceDir)).to.include('greeting: hello-prod'); + }); + it('should reject variable syntax errors in params of other stages', async () => { const { servicePath: serviceDir } = await setupProgrammaticFixture('aws', { configExt: { From 082805bc3db2905c0235aad60d6ae31266e0a7e5 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Tue, 4 Aug 2026 16:37:38 +0100 Subject: [PATCH 3/6] Defer params discovered in sections resolved from a single variable --- .../defer-irrelevant-stage-params.js | 9 ++- lib/configuration/variables/resolve.js | 12 ++- .../configuration/variables/resolve.test.js | 73 +++++++++++++++++++ test/unit/scripts/serverless.test.js | 64 ++++++++++++++++ 4 files changed, 150 insertions(+), 8 deletions(-) diff --git a/lib/configuration/variables/defer-irrelevant-stage-params.js b/lib/configuration/variables/defer-irrelevant-stage-params.js index 61e40a5c79..7b523da9e4 100644 --- a/lib/configuration/variables/defer-irrelevant-stage-params.js +++ b/lib/configuration/variables/defer-irrelevant-stage-params.js @@ -9,10 +9,11 @@ // still deferred after the final resolution pass are dropped from `variablesMeta` by the caller // (see `scripts/serverless.js`), leaving their raw (unresolved) values in the configuration. // -// Known limitation: when a whole section resolves from a single variable -// (e.g. `params: ${file(./params.yml)}` or `params.prod: ${file(./prod-params.yml)}`), it is -// resolved eagerly as before. Such a section cannot be left unresolved: the configuration schema -// requires `params.` to be an object, and a raw variable string would fail validation. +// Known limitation: when a whole section is configured with a single variable +// (e.g. `params: ${file(./params.yml)}` or `params.prod: ${file(./prod-params.yml)}`), the +// section variable itself is resolved eagerly: the configuration schema requires +// `params.` to be an object, and a raw variable string would fail validation. +// Values nested in the resolved object are however deferred as any other. 'use strict'; diff --git a/lib/configuration/variables/resolve.js b/lib/configuration/variables/resolve.js index 58ccf28e5d..4e3043450e 100644 --- a/lib/configuration/variables/resolve.js +++ b/lib/configuration/variables/resolve.js @@ -573,12 +573,16 @@ Object.defineProperties( return; } - // Resolve variables found in resolved value + // Resolve variables found in resolved value. + // Deferred properties (e.g. params of irrelevant stages discovered in a section resolved + // from a single variable) are registered above but not scheduled: they still resolve on + // demand when another property depends on them (see `resolveDependentProperty`) const newResolutionBatchId = ++lastResolutionBatchId; await Promise.all( - Array.from(propertyVariablesMeta.keys()).map((subPropertyPath) => - this.resolveProperty(newResolutionBatchId, subPropertyPath) - ) + Array.from(propertyVariablesMeta.keys()).map((subPropertyPath) => { + if (this.isPropertyDeferred(subPropertyPath)) return null; + return this.resolveProperty(newResolutionBatchId, subPropertyPath); + }) ); }, { diff --git a/test/unit/lib/configuration/variables/resolve.test.js b/test/unit/lib/configuration/variables/resolve.test.js index 2970de7708..b23f9c7b11 100644 --- a/test/unit/lib/configuration/variables/resolve.test.js +++ b/test/unit/lib/configuration/variables/resolve.test.js @@ -743,4 +743,77 @@ describe('test/unit/lib/configuration/variables/resolve.test.js', () => { expect(callCount).to.equal(1); }); }); + + describe('Deferred resolution of dynamically discovered properties', () => { + const configuration = { + params: { + dev: { greeting: '${sourceDirect:}' }, + prod: '${sourceObject:}', + }, + referencesDeferred: '${sourceProperty(params, prod, referenced)}', + }; + let variablesMeta; + let callCount = 0; + const sources = { + sourceDirect: { + resolve: () => ({ value: 'hello' }), + }, + sourceCounted: { + resolve: () => { + ++callCount; + return { value: `counted:${callCount}` }; + }, + }, + sourceObject: { + resolve: () => ({ + value: { referenced: '${sourceCounted:}', notReferenced: '${sourceCounted:}' }, + }), + }, + sourceProperty: { + resolve: async ({ params, resolveConfigurationProperty }) => { + const result = await resolveConfigurationProperty(params || []); + return { value: result == null ? null : result }; + }, + }, + }; + before(async () => { + variablesMeta = resolveMeta(configuration); + await resolve({ + serviceDir: process.cwd(), + configuration, + variablesMeta, + sources, + options: {}, + fulfilledSources: new Set([ + 'sourceDirect', + 'sourceCounted', + 'sourceObject', + 'sourceProperty', + ]), + isPropertyDeferred: (propertyPath) => { + if (!propertyPath.startsWith('params\0')) return false; + const propertyPathKeys = propertyPath.split('\0'); + if (propertyPathKeys.length < 3) return false; + return propertyPathKeys[1] !== 'dev' && propertyPathKeys[1] !== 'default'; + }, + }); + }); + + it('should resolve section variables', () => { + expect(variablesMeta.has('params\0prod')).to.be.false; + expect(configuration.params.dev.greeting).to.equal('hello'); + }); + it('should not resolve deferred properties discovered in resolved values', () => { + expect(configuration.params.prod.notReferenced).to.equal('${sourceCounted:}'); + expect(variablesMeta.get('params\0prod\0notReferenced')).to.have.property('variables'); + }); + it('should resolve discovered properties on which other properties depend', () => { + expect(configuration.params.prod.referenced).to.equal('counted:1'); + expect(configuration.referencesDeferred).to.equal('counted:1'); + expect(variablesMeta.has('params\0prod\0referenced')).to.be.false; + }); + it('should not invoke sources of deferred discovered properties', () => { + expect(callCount).to.equal(1); + }); + }); }); diff --git a/test/unit/scripts/serverless.test.js b/test/unit/scripts/serverless.test.js index d879b7e157..27942d61e9 100644 --- a/test/unit/scripts/serverless.test.js +++ b/test/unit/scripts/serverless.test.js @@ -463,6 +463,70 @@ describe('test/unit/scripts/serverless.test.js', () => { expect(output).to.include('apiKey: real-prod-key'); }); + it('should not resolve params nested in other stage sections resolved from a single variable', async () => { + const { servicePath: serviceDir } = await setupProgrammaticFixture('aws', { + configExt: { + params: { + dev: { greeting: 'hello-dev' }, + prod: '${file(./prod-params.yml)}', + }, + custom: { greeting: '${param:greeting}' }, + }, + files: [ + { to: 'prod-params.yml', contents: 'secret: ${env:OSLS_TEST_SURELY_MISSING_ENV_VAR}' }, + ], + }); + const output = await print(serviceDir); + expect(output).to.include('greeting: hello-dev'); + expect(output).to.include('${env:OSLS_TEST_SURELY_MISSING_ENV_VAR}'); + }); + + it('should not resolve other stage params nested in a whole "params" section resolved from a single variable', async () => { + const { servicePath: serviceDir } = await setupProgrammaticFixture('aws', { + configExt: { + params: '${file(./params.yml)}', + custom: { greeting: '${param:greeting}' }, + }, + files: [ + { + to: 'params.yml', + contents: [ + 'dev:', + ' greeting: hello-dev', + 'prod:', + ' secret: ${env:OSLS_TEST_SURELY_MISSING_ENV_VAR}', + ].join('\n'), + }, + ], + }); + const output = await print(serviceDir); + expect(output).to.include('greeting: hello-dev'); + expect(output).to.include('${env:OSLS_TEST_SURELY_MISSING_ENV_VAR}'); + }); + + it('should not resolve siblings of referenced params of other stages', async () => { + const { servicePath: serviceDir } = await setupProgrammaticFixture('aws', { + configExt: { + params: { + prod: '${file(./prod-params.yml)}', + }, + custom: { prodDomain: '${self:params.prod.domain}' }, + }, + files: [ + { + to: 'prod-params.yml', + contents: [ + 'domain: prod.example.com', + 'secret: ${env:OSLS_TEST_SURELY_MISSING_ENV_VAR}', + ].join('\n'), + }, + ], + }); + const output = await print(serviceDir); + expect(output).to.include('prodDomain: prod.example.com'); + expect(output).to.include('${env:OSLS_TEST_SURELY_MISSING_ENV_VAR}'); + }); + it('should resolve "self" references nested in other stage params', async () => { const { servicePath: serviceDir } = await setupProgrammaticFixture('aws', { configExt: { From 740d0cc0ec7156a61a56c525c4c896aae372a074 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Tue, 4 Aug 2026 16:38:08 +0100 Subject: [PATCH 4/6] Keep reporting syntax errors in deferred params --- scripts/serverless.js | 9 ++++--- test/unit/scripts/serverless.test.js | 38 ++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/scripts/serverless.js b/scripts/serverless.js index 15ee9bd4d6..5d25a6c8fb 100755 --- a/scripts/serverless.js +++ b/scripts/serverless.js @@ -660,11 +660,14 @@ process.once('uncaughtException', (error) => { // Drop entries which were deliberately left unresolved (params of irrelevant stages), // so they're not reported as resolution errors or unrecognized sources. // Their raw values remain in the configuration. + // Entries which already errored are kept, so they're still reported below: those carry + // variable syntax errors surfaced when a section resolved to an object, or failures of + // properties which were resolved on demand. // Note: this must not run while a resolution pass is in progress for (const propertyPath of Array.from(variablesMeta.keys())) { - if (resolverConfiguration.isPropertyDeferred(propertyPath)) { - variablesMeta.delete(propertyPath); - } + if (!resolverConfiguration.isPropertyDeferred(propertyPath)) continue; + if (variablesMeta.get(propertyPath).error) continue; + variablesMeta.delete(propertyPath); } if (!variablesMeta.size) return; diff --git a/test/unit/scripts/serverless.test.js b/test/unit/scripts/serverless.test.js index 27942d61e9..6621e6b19b 100644 --- a/test/unit/scripts/serverless.test.js +++ b/test/unit/scripts/serverless.test.js @@ -614,5 +614,43 @@ describe('test/unit/scripts/serverless.test.js', () => { expect(output).to.include('greeting: hello-dev'); expect(output).to.include('${env:OSLS_TEST_SURELY_MISSING_ENV_VAR}'); }); + + it('should report syntax errors in other stage params resolved in the final phase', async () => { + const { servicePath: serviceDir } = await setupProgrammaticFixture('aws', { + configExt: { + plugins: ['./source-plugin'], + params: { + dev: { greeting: 'hello-dev' }, + prod: '${sourceObject:}', + }, + custom: { greeting: '${param:greeting}' }, + }, + files: [ + { + to: 'source-plugin.js', + contents: [ + "'use strict';", + 'module.exports = class SourcePlugin {', + ' constructor() {', + ' this.configurationVariablesSources = {', + ' sourceObject: {', + " resolve: async () => ({ value: { bad: '${env:UNCLOSED' } }),", + ' },', + ' };', + ' }', + '};', + '', + ].join('\n'), + }, + ], + }); + try { + await print(serviceDir); + throw new Error('Unexpected'); + } catch (error) { + expect(error.code).to.equal(1); + expect(String(error.stdoutBuffer)).to.include('params.prod.bad'); + } + }); }); }); From 85ffd17504b48780627b3a1791e287bfdbc2f39a Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Tue, 4 Aug 2026 16:38:30 +0100 Subject: [PATCH 5/6] Document exceptions to deferred params resolution --- docs/guides/parameters.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/guides/parameters.md b/docs/guides/parameters.md index d7144a598b..7719a8a49c 100644 --- a/docs/guides/parameters.md +++ b/docs/guides/parameters.md @@ -74,6 +74,12 @@ Variables set in `params.` sections that do not concern the current stage Such variables are still resolved when they are explicitly referenced, e.g. with `${self:params.prod.domain}`. +There are a few exceptions: + +- Params under `params.default` are always resolved, as they apply to every stage. +- When a whole section is defined with a single variable (e.g. `params: ${file(./params.yml)}` or `params.prod: ${file(./prod-params.yml)}`), that variable itself is still resolved: the configuration schema requires these sections to be objects. Values nested in the result are however only resolved for the current stage. +- Variable syntax errors are reported for all stages: a malformed variable in `params.prod` still fails the command when deploying to `dev`. + One consequence is that `serverless print` displays these values unresolved, as they appear in `serverless.yml`: ```yaml From b0c2f96eebe8a3a7a22ae165fca13be8ea367b02 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Tue, 4 Aug 2026 16:46:43 +0100 Subject: [PATCH 6/6] Trim and re-wrap comments --- .../defer-irrelevant-stage-params.js | 39 +++++++++---------- lib/configuration/variables/resolve.js | 12 +++--- scripts/serverless.js | 15 +++---- 3 files changed, 29 insertions(+), 37 deletions(-) diff --git a/lib/configuration/variables/defer-irrelevant-stage-params.js b/lib/configuration/variables/defer-irrelevant-stage-params.js index 7b523da9e4..8a726e6e45 100644 --- a/lib/configuration/variables/defer-irrelevant-stage-params.js +++ b/lib/configuration/variables/defer-irrelevant-stage-params.js @@ -1,19 +1,16 @@ -// Builds an `isPropertyDeferred` predicate for the variables resolver, which prevents eager -// resolution of `params.` sections that do not concern the effective stage. -// Such sections are never read by the "param" variable source (which only consults -// `params.` and `params.default`), so resolving them upfront at best wastes -// remote calls (e.g. SSM) and at worst fails the command on errors that concern other stages. +// Builds an `isPropertyDeferred` predicate which prevents eager resolution of `params.` +// sections not concerning the effective stage. The "param" source only reads +// `params.` and `params.default`, so resolving other sections at best wastes remote +// calls (e.g. SSM) and at worst fails the command on errors of irrelevant stages. // -// Deferred properties stay in `variablesMeta`: an explicit reference (e.g. -// `${self:params.prod.x}`) still resolves them on demand, exactly as before. Entries that are -// still deferred after the final resolution pass are dropped from `variablesMeta` by the caller -// (see `scripts/serverless.js`), leaving their raw (unresolved) values in the configuration. +// Deferred properties stay in `variablesMeta`, so explicit references (e.g. +// `${self:params.prod.x}`) still resolve them on demand. Entries left deferred after the final +// pass are dropped by the caller (see `scripts/serverless.js`), leaving their raw values in the +// configuration. // -// Known limitation: when a whole section is configured with a single variable -// (e.g. `params: ${file(./params.yml)}` or `params.prod: ${file(./prod-params.yml)}`), the -// section variable itself is resolved eagerly: the configuration schema requires -// `params.` to be an object, and a raw variable string would fail validation. -// Values nested in the resolved object are however deferred as any other. +// Known limitation: a whole section configured with a single variable (e.g. +// `params.prod: ${file(./prod-params.yml)}`) is still resolved, as the configuration schema +// requires it to be an object. Values nested in the resolved object are deferred as any other. 'use strict'; @@ -21,15 +18,15 @@ const isPlainObject = require('type/plain-object/is'); const { hasOwn } = require('../../utils/safe-object'); // `configuration` and `resolverConfiguration` are read live at each call (not snapshotted): -// plugins may extend the configuration with new stages, and the effective CLI options object -// is replaced between resolution passes +// plugins may extend the configuration with new stages, and the effective CLI options object is +// replaced between resolution passes module.exports = (configuration, resolverConfiguration) => (propertyPath) => { if (!propertyPath.startsWith('params\0')) return false; if (!isPlainObject(configuration.params)) return false; const propertyPathKeys = propertyPath.split('\0'); - // Never defer the `params.` section itself: it has to resolve for the configuration - // to validate (see "Known limitation" above) + // Never defer the `params.` section itself: it has to resolve for the configuration to + // validate (see "Known limitation" above) if (propertyPathKeys.length < 3) return false; const stageKey = propertyPathKeys[1]; @@ -47,9 +44,9 @@ module.exports = (configuration, resolverConfiguration) => (propertyPath) => { } if (stage == null) stage = 'dev'; stage = String(stage); - // The effective stage is not known yet (`provider.stage` is configured behind a variable which - // did not resolve yet). Deferring against it would be resolved against a different stage in a - // later pass, which would strand params of the effective stage as never scheduled for resolution + // The effective stage is not known yet (`provider.stage` is itself behind an unresolved + // variable). Deferring against it could strand params of the actual effective stage as never + // scheduled for resolution if (stage.includes('${')) return false; return stageKey !== stage; diff --git a/lib/configuration/variables/resolve.js b/lib/configuration/variables/resolve.js index 4e3043450e..255c32b610 100644 --- a/lib/configuration/variables/resolve.js +++ b/lib/configuration/variables/resolve.js @@ -86,9 +86,9 @@ class VariablesResolver { this.sources = sources; this.options = options; this.fulfilledSources = fulfilledSources; - // Matching properties are not scheduled for resolution in this pass. - // They remain in `variablesMeta`, so they still resolve on demand when another property - // depends on them (see `resolveDependentProperty`) + // Matching properties are not scheduled for resolution in this pass. They remain in + // `variablesMeta`, so they still resolve on demand when another property depends on them + // (see `resolveDependentProperty`) this.isPropertyDeferred = isPropertyDeferred || (() => false); this.propertyDependenciesMap = new Map(); this.propertyResolutionNestDepthMap = new Map(); @@ -573,10 +573,8 @@ Object.defineProperties( return; } - // Resolve variables found in resolved value. - // Deferred properties (e.g. params of irrelevant stages discovered in a section resolved - // from a single variable) are registered above but not scheduled: they still resolve on - // demand when another property depends on them (see `resolveDependentProperty`) + // Resolve variables found in resolved value. Deferred properties are registered above + // but not scheduled: they still resolve on demand (see `resolveDependentProperty`) const newResolutionBatchId = ++lastResolutionBatchId; await Promise.all( Array.from(propertyVariablesMeta.keys()).map((subPropertyPath) => { diff --git a/scripts/serverless.js b/scripts/serverless.js index 5d25a6c8fb..e6ab0804a9 100755 --- a/scripts/serverless.js +++ b/scripts/serverless.js @@ -577,8 +577,8 @@ process.once('uncaughtException', (error) => { ? new Set(['plugins', 'provider\0name', 'provider\0stage', 'useDotenv']) : null, }; - // Assigned after the literal, as the predicate reads `resolverConfiguration` live. - // When `resolverConfiguration` was already setup above, it carries its own predicate + // Assigned after the literal, as the predicate reads `resolverConfiguration` live + // (when set up above instead, `resolverConfiguration` already carries its predicate) resolverConfiguration.isPropertyDeferred = deferIrrelevantStageParams( configuration, resolverConfiguration @@ -657,13 +657,10 @@ process.once('uncaughtException', (error) => { processLog.debug('resolve all variables'); await resolveVariables(resolverConfiguration); - // Drop entries which were deliberately left unresolved (params of irrelevant stages), - // so they're not reported as resolution errors or unrecognized sources. - // Their raw values remain in the configuration. - // Entries which already errored are kept, so they're still reported below: those carry - // variable syntax errors surfaced when a section resolved to an object, or failures of - // properties which were resolved on demand. - // Note: this must not run while a resolution pass is in progress + // Drop entries deliberately left unresolved (params of irrelevant stages), so they're not + // reported as resolution errors or unrecognized sources; their raw values remain in the + // configuration. Errored entries (e.g. syntax errors in resolved objects) are kept to be + // reported below. Must not run while a resolution pass is in progress for (const propertyPath of Array.from(variablesMeta.keys())) { if (!resolverConfiguration.isPropertyDeferred(propertyPath)) continue; if (variablesMeta.get(propertyPath).error) continue;