From 63893433d2d8fd5490c99c860009048a8df1e774 Mon Sep 17 00:00:00 2001 From: sanjanaravikumar-az Date: Thu, 14 May 2026 12:18:42 -0400 Subject: [PATCH 1/4] feat(cli): warn on unrecognized CLI options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Yargs does not enable strict option checking by default, so unknown flags are silently swallowed. This adds a warning when unrecognized options are detected, helping users catch typos early. Handles edge cases: - Skips undefined-value keys (yargs default placeholders from other commands) - Skips camelCase variants of known kebab-case options - Skips boolean negation keys (noFoo for --no-foo) - Skips negativeAlias keys (e.g. R for --no-rollback) - Skips keys injected by yargs .env('CDK') from CDK_* environment variables Closes aws/aws-cdk-cli#928 (partial — related to rix0rrr's strict parsing suggestion) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/aws-cdk/lib/cli/cli.ts | 7 + .../lib/cli/util/check-unknown-options.ts | 92 +++++++++++++ .../cli/util/check-unknown-options.test.ts | 121 ++++++++++++++++++ 3 files changed, 220 insertions(+) create mode 100644 packages/aws-cdk/lib/cli/util/check-unknown-options.ts create mode 100644 packages/aws-cdk/test/cli/util/check-unknown-options.test.ts diff --git a/packages/aws-cdk/lib/cli/cli.ts b/packages/aws-cdk/lib/cli/cli.ts index 239e85874..8b09c0fea 100644 --- a/packages/aws-cdk/lib/cli/cli.ts +++ b/packages/aws-cdk/lib/cli/cli.ts @@ -38,6 +38,7 @@ import { getLanguageFromAlias } from '../commands/language'; import { getMigrateScanType } from '../commands/migrate'; import { execProgram, CloudExecutable } from '../cxapp'; import type { StackSelector, Synthesizer } from '../cxapp'; +import { findUnknownOptions } from './util/check-unknown-options'; import { isCI } from './util/ci'; import { guessAgent } from './util/guess-agent'; @@ -99,6 +100,12 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise 0) { + const formatted = unknownOptions.map((o) => `--${o}`).join(', '); + await ioHost.defaults.warn(`Unknown option(s): ${formatted}. These will be ignored. Run 'cdk --help' to see available options.`); + } + const configuration = await Configuration.fromArgsAndFiles(ioHelper, { commandLineArguments: { diff --git a/packages/aws-cdk/lib/cli/util/check-unknown-options.ts b/packages/aws-cdk/lib/cli/util/check-unknown-options.ts new file mode 100644 index 000000000..356671d46 --- /dev/null +++ b/packages/aws-cdk/lib/cli/util/check-unknown-options.ts @@ -0,0 +1,92 @@ +/** + * Detect unrecognized CLI options and emit warnings. + * + * Yargs does not enable strict option checking by default, so unknown flags + * like `--region` (before it was added) are silently swallowed. This function + * compares the parsed argv keys against the known global and command options + * from the CLI type registry and warns for any that don't match. + */ +export function findUnknownOptions(argv: any): string[] { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const config = require('../cli-type-registry.json'); + const command = argv._[0]; + + const globalOptions = new Set(Object.keys(config.globalOptions)); + const commandOptions = new Set(Object.keys(config.commands[command]?.options ?? {})); + + // Collect all known aliases and negativeAliases + for (const [, optDef] of Object.entries(config.globalOptions)) { + if (optDef.alias) { + const aliases = Array.isArray(optDef.alias) ? optDef.alias : [optDef.alias]; + for (const a of aliases) { + globalOptions.add(a); + } + } + if (optDef.negativeAlias) { + globalOptions.add(optDef.negativeAlias); + } + } + for (const [, optDef] of Object.entries(config.commands[command]?.options ?? {})) { + if (optDef.alias) { + const aliases = Array.isArray(optDef.alias) ? optDef.alias : [optDef.alias]; + for (const a of aliases) { + commandOptions.add(a); + } + } + if (optDef.negativeAlias) { + commandOptions.add(optDef.negativeAlias); + } + } + + // yargs internal keys to ignore + const yargsInternals = new Set(['_', '$0', 'help', 'h', 'version']); + + // The command's positional arg name + const commandArg = config.commands[command]?.arg?.name; + if (commandArg) { + yargsInternals.add(commandArg); + } + + const unknown: string[] = []; + for (const key of Object.keys(argv)) { + if (argv[key] === undefined) continue; + if (yargsInternals.has(key)) continue; + if (globalOptions.has(key)) continue; + if (commandOptions.has(key)) continue; + + // yargs creates camelCase versions of kebab-case options — skip those + const kebab = camelToKebab(key); + if (kebab !== key && (globalOptions.has(kebab) || commandOptions.has(kebab))) continue; + + // yargs creates "noFoo" keys for --no-foo boolean negations — skip those + if (key.startsWith('no') && key.length > 2 && key[2] === key[2].toUpperCase()) { + const positiveKey = key[2].toLowerCase() + key.slice(3); + const positiveKebab = camelToKebab(positiveKey); + if (globalOptions.has(positiveKey) || commandOptions.has(positiveKey) || + globalOptions.has(positiveKebab) || commandOptions.has(positiveKebab)) continue; + } + + // yargs .env('CDK') injects CDK_* environment variables as camelCase argv + // keys (e.g. CDK_INTEG_ATMOSPHERE_POOL -> integAtmospherePool). These are + // intentional configuration from the environment, not user typos. + if (isFromEnvPrefix(key, 'CDK')) continue; + + unknown.push(key); + } + + return unknown; +} + +function camelToKebab(str: string): string { + return str.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); +} + +/** + * Checks whether a camelCase argv key was injected by yargs' .env(PREFIX) + * feature. yargs converts PREFIX_FOO_BAR env vars into camelCase keys + * (fooBar). We reverse the mapping and check if the env var exists. + */ +function isFromEnvPrefix(key: string, prefix: string): boolean { + const screamingSnake = key.replace(/[A-Z]/g, (m) => `_${m}`).toUpperCase(); + return process.env[`${prefix}_${screamingSnake}`] !== undefined; +} diff --git a/packages/aws-cdk/test/cli/util/check-unknown-options.test.ts b/packages/aws-cdk/test/cli/util/check-unknown-options.test.ts new file mode 100644 index 000000000..672aa19bb --- /dev/null +++ b/packages/aws-cdk/test/cli/util/check-unknown-options.test.ts @@ -0,0 +1,121 @@ +import { findUnknownOptions } from '../../../lib/cli/util/check-unknown-options'; + +describe('findUnknownOptions', () => { + test('returns empty array for known global options', () => { + const argv = { + _: ['deploy'], + $0: 'cdk', + profile: 'my-profile', + region: 'us-west-2', + verbose: 1, + }; + expect(findUnknownOptions(argv)).toEqual([]); + }); + + test('returns empty array for known command options', () => { + const argv = { + _: ['deploy'], + $0: 'cdk', + force: true, + all: false, + }; + expect(findUnknownOptions(argv)).toEqual([]); + }); + + test('detects unknown options', () => { + const argv = { + _: ['bootstrap'], + $0: 'cdk', + profile: 'my-profile', + fakeOption: 'value', + }; + const unknown = findUnknownOptions(argv); + expect(unknown).toContain('fakeOption'); + }); + + test('does not report camelCase variants of known kebab-case options', () => { + const argv = { + '_': ['deploy'], + '$0': 'cdk', + 'ca-bundle-path': '/tmp/ca.pem', + 'caBundlePath': '/tmp/ca.pem', + }; + expect(findUnknownOptions(argv)).toEqual([]); + }); + + test('does not report yargs internal keys', () => { + const argv = { + _: ['deploy'], + $0: 'cdk', + help: false, + h: false, + version: false, + }; + expect(findUnknownOptions(argv)).toEqual([]); + }); + + test('does not report aliases', () => { + const argv = { + _: ['deploy'], + $0: 'cdk', + v: 1, + j: false, + a: 'node bin/app.js', + }; + expect(findUnknownOptions(argv)).toEqual([]); + }); + + test('does not report yargs boolean negation keys (noFoo for --no-foo)', () => { + const argv = { + _: ['deploy'], + $0: 'cdk', + rollback: false, + noRollback: true, + }; + expect(findUnknownOptions(argv)).toEqual([]); + }); + + test('does not report negativeAlias keys', () => { + const argv = { + _: ['deploy'], + $0: 'cdk', + R: true, + rollback: false, + }; + expect(findUnknownOptions(argv)).toEqual([]); + }); + + test('does not report keys injected by yargs .env("CDK") from environment variables', () => { + process.env.CDK_INTEG_ATMOSPHERE_POOL = 'test-pool'; + process.env.CDK_MAJOR_VERSION = '2'; + try { + const argv = { + _: ['deploy'], + $0: 'cdk', + integAtmospherePool: 'test-pool', + majorVersion: '2', + }; + expect(findUnknownOptions(argv)).toEqual([]); + } finally { + delete process.env.CDK_INTEG_ATMOSPHERE_POOL; + delete process.env.CDK_MAJOR_VERSION; + } + }); + + test('still reports truly unknown options even when CDK_ env vars exist', () => { + process.env.CDK_INTEG_ATMOSPHERE_POOL = 'test-pool'; + try { + const argv = { + _: ['deploy'], + $0: 'cdk', + integAtmospherePool: 'test-pool', + totallyFakeOption: 'value', + }; + const unknown = findUnknownOptions(argv); + expect(unknown).not.toContain('integAtmospherePool'); + expect(unknown).toContain('totallyFakeOption'); + } finally { + delete process.env.CDK_INTEG_ATMOSPHERE_POOL; + } + }); +}); From 14c05c24948f508f9f7544c07bd72a639e1ae980 Mon Sep 17 00:00:00 2001 From: sanjanaravikumar-az Date: Thu, 14 May 2026 12:35:16 -0400 Subject: [PATCH 2/4] fix: remove region from test since it's not yet a registered option on main Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/aws-cdk/test/cli/util/check-unknown-options.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/aws-cdk/test/cli/util/check-unknown-options.test.ts b/packages/aws-cdk/test/cli/util/check-unknown-options.test.ts index 672aa19bb..86d9f851e 100644 --- a/packages/aws-cdk/test/cli/util/check-unknown-options.test.ts +++ b/packages/aws-cdk/test/cli/util/check-unknown-options.test.ts @@ -6,7 +6,6 @@ describe('findUnknownOptions', () => { _: ['deploy'], $0: 'cdk', profile: 'my-profile', - region: 'us-west-2', verbose: 1, }; expect(findUnknownOptions(argv)).toEqual([]); From a8d7d4b4d3de382686efafd7673b4fd46db32070 Mon Sep 17 00:00:00 2001 From: sanjanaravikumar-az Date: Fri, 15 May 2026 12:52:55 -0400 Subject: [PATCH 3/4] fix: address review feedback on unknown options detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move static config and global options set to module scope - Replace camelToKebab with kebabToCamel (applied at set-construction time) - Remove unnecessary isNegationOfKnownOption — verified that yargs does NOT create "noFoo" keys from --no-foo flags; the only noFoo keys come from CDK_NO_FOO env vars which isFromEnvPrefix already handles - Always use braces for if/continue statements - Combine the noFoo negation check into isFromEnvPrefix coverage - Add explanatory comment on env var test Co-Authored-By: Claude Opus 4.6 (1M context) --- .../lib/cli/util/check-unknown-options.ts | 110 +++++++++--------- .../cli/util/check-unknown-options.test.ts | 12 +- 2 files changed, 54 insertions(+), 68 deletions(-) diff --git a/packages/aws-cdk/lib/cli/util/check-unknown-options.ts b/packages/aws-cdk/lib/cli/util/check-unknown-options.ts index 356671d46..d640c3af8 100644 --- a/packages/aws-cdk/lib/cli/util/check-unknown-options.ts +++ b/packages/aws-cdk/lib/cli/util/check-unknown-options.ts @@ -1,75 +1,69 @@ +// eslint-disable-next-line @typescript-eslint/no-require-imports +const config = require('../cli-type-registry.json'); + /** - * Detect unrecognized CLI options and emit warnings. - * - * Yargs does not enable strict option checking by default, so unknown flags - * like `--region` (before it was added) are silently swallowed. This function - * compares the parsed argv keys against the known global and command options - * from the CLI type registry and warns for any that don't match. + * Build a set of all known option names for a given option definitions object. + * Includes the kebab-case name, its camelCase equivalent, all aliases, and negativeAliases. */ -export function findUnknownOptions(argv: any): string[] { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const config = require('../cli-type-registry.json'); - const command = argv._[0]; - - const globalOptions = new Set(Object.keys(config.globalOptions)); - const commandOptions = new Set(Object.keys(config.commands[command]?.options ?? {})); - - // Collect all known aliases and negativeAliases - for (const [, optDef] of Object.entries(config.globalOptions)) { - if (optDef.alias) { - const aliases = Array.isArray(optDef.alias) ? optDef.alias : [optDef.alias]; +function collectKnownOptions(optionDefs: Record): Set { + const known = new Set(); + for (const [name, def] of Object.entries(optionDefs)) { + known.add(name); + known.add(kebabToCamel(name)); + if (def.alias) { + const aliases = Array.isArray(def.alias) ? def.alias : [def.alias]; for (const a of aliases) { - globalOptions.add(a); + known.add(a); } } - if (optDef.negativeAlias) { - globalOptions.add(optDef.negativeAlias); - } - } - for (const [, optDef] of Object.entries(config.commands[command]?.options ?? {})) { - if (optDef.alias) { - const aliases = Array.isArray(optDef.alias) ? optDef.alias : [optDef.alias]; - for (const a of aliases) { - commandOptions.add(a); - } - } - if (optDef.negativeAlias) { - commandOptions.add(optDef.negativeAlias); + if (def.negativeAlias) { + known.add(def.negativeAlias); } } + return known; +} - // yargs internal keys to ignore - const yargsInternals = new Set(['_', '$0', 'help', 'h', 'version']); +/** Pre-computed set of known global options (static, doesn't depend on argv) */ +const globalKnownOptions = collectKnownOptions(config.globalOptions); - // The command's positional arg name - const commandArg = config.commands[command]?.arg?.name; - if (commandArg) { - yargsInternals.add(commandArg); - } +/** yargs internal keys that are always present in argv */ +const yargsInternals = new Set(['_', '$0', 'help', 'h', 'version']); - const unknown: string[] = []; - for (const key of Object.keys(argv)) { - if (argv[key] === undefined) continue; - if (yargsInternals.has(key)) continue; - if (globalOptions.has(key)) continue; - if (commandOptions.has(key)) continue; +/** + * Detect unrecognized CLI options. + * + * Yargs does not enable strict option checking by default, so unknown flags + * are silently swallowed. This function compares the parsed argv keys against + * the known global and command options from the CLI type registry and returns + * any that don't match. + */ +export function findUnknownOptions(argv: any): string[] { + const command = argv._[0]; - // yargs creates camelCase versions of kebab-case options — skip those - const kebab = camelToKebab(key); - if (kebab !== key && (globalOptions.has(kebab) || commandOptions.has(kebab))) continue; + const commandDef = config.commands[command]; + const commandKnownOptions = commandDef?.options + ? collectKnownOptions(commandDef.options) + : new Set(); - // yargs creates "noFoo" keys for --no-foo boolean negations — skip those - if (key.startsWith('no') && key.length > 2 && key[2] === key[2].toUpperCase()) { - const positiveKey = key[2].toLowerCase() + key.slice(3); - const positiveKebab = camelToKebab(positiveKey); - if (globalOptions.has(positiveKey) || commandOptions.has(positiveKey) || - globalOptions.has(positiveKebab) || commandOptions.has(positiveKebab)) continue; - } + const positionalArg = commandDef?.arg?.name; + const unknown: string[] = []; + for (const key of Object.keys(argv)) { + if (argv[key] === undefined) { + continue; + } + if (yargsInternals.has(key) || key === positionalArg) { + continue; + } + if (globalKnownOptions.has(key) || commandKnownOptions.has(key)) { + continue; + } // yargs .env('CDK') injects CDK_* environment variables as camelCase argv // keys (e.g. CDK_INTEG_ATMOSPHERE_POOL -> integAtmospherePool). These are // intentional configuration from the environment, not user typos. - if (isFromEnvPrefix(key, 'CDK')) continue; + if (isFromEnvPrefix(key, 'CDK')) { + continue; + } unknown.push(key); } @@ -77,8 +71,8 @@ export function findUnknownOptions(argv: any): string[] { return unknown; } -function camelToKebab(str: string): string { - return str.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); +function kebabToCamel(str: string): string { + return str.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); } /** diff --git a/packages/aws-cdk/test/cli/util/check-unknown-options.test.ts b/packages/aws-cdk/test/cli/util/check-unknown-options.test.ts index 86d9f851e..cd31922f9 100644 --- a/packages/aws-cdk/test/cli/util/check-unknown-options.test.ts +++ b/packages/aws-cdk/test/cli/util/check-unknown-options.test.ts @@ -64,16 +64,6 @@ describe('findUnknownOptions', () => { expect(findUnknownOptions(argv)).toEqual([]); }); - test('does not report yargs boolean negation keys (noFoo for --no-foo)', () => { - const argv = { - _: ['deploy'], - $0: 'cdk', - rollback: false, - noRollback: true, - }; - expect(findUnknownOptions(argv)).toEqual([]); - }); - test('does not report negativeAlias keys', () => { const argv = { _: ['deploy'], @@ -84,6 +74,8 @@ describe('findUnknownOptions', () => { expect(findUnknownOptions(argv)).toEqual([]); }); + // yargs .env('CDK') injects CDK_* env vars as camelCase keys in argv. + // This also covers "noFoo" patterns (e.g. CDK_NO_ROLLBACK -> noRollback). test('does not report keys injected by yargs .env("CDK") from environment variables', () => { process.env.CDK_INTEG_ATMOSPHERE_POOL = 'test-pool'; process.env.CDK_MAJOR_VERSION = '2'; From 91904c0b67d25a3b7087fd2252fb9adaddc85798 Mon Sep 17 00:00:00 2001 From: sanjanaravikumar-az Date: Mon, 18 May 2026 13:12:14 -0400 Subject: [PATCH 4/4] fix: address iankhou review feedback - Add @param JSDoc to collectKnownOptions clarifying that input keys are expected in kebab-case - Use toEqual instead of toContain in tests for stricter assertions Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/aws-cdk/lib/cli/util/check-unknown-options.ts | 4 ++++ packages/aws-cdk/test/cli/util/check-unknown-options.test.ts | 5 ++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/aws-cdk/lib/cli/util/check-unknown-options.ts b/packages/aws-cdk/lib/cli/util/check-unknown-options.ts index d640c3af8..dc86eb1f8 100644 --- a/packages/aws-cdk/lib/cli/util/check-unknown-options.ts +++ b/packages/aws-cdk/lib/cli/util/check-unknown-options.ts @@ -4,6 +4,10 @@ const config = require('../cli-type-registry.json'); /** * Build a set of all known option names for a given option definitions object. * Includes the kebab-case name, its camelCase equivalent, all aliases, and negativeAliases. + * + * @param optionDefs - Option definitions keyed by their kebab-case CLI names + * (as defined in cli-type-registry.json). Each key is converted to camelCase + * and both forms are included in the returned set. */ function collectKnownOptions(optionDefs: Record): Set { const known = new Set(); diff --git a/packages/aws-cdk/test/cli/util/check-unknown-options.test.ts b/packages/aws-cdk/test/cli/util/check-unknown-options.test.ts index cd31922f9..b00c01c66 100644 --- a/packages/aws-cdk/test/cli/util/check-unknown-options.test.ts +++ b/packages/aws-cdk/test/cli/util/check-unknown-options.test.ts @@ -29,7 +29,7 @@ describe('findUnknownOptions', () => { fakeOption: 'value', }; const unknown = findUnknownOptions(argv); - expect(unknown).toContain('fakeOption'); + expect(unknown).toEqual(['fakeOption']); }); test('does not report camelCase variants of known kebab-case options', () => { @@ -103,8 +103,7 @@ describe('findUnknownOptions', () => { totallyFakeOption: 'value', }; const unknown = findUnknownOptions(argv); - expect(unknown).not.toContain('integAtmospherePool'); - expect(unknown).toContain('totallyFakeOption'); + expect(unknown).toEqual(['totallyFakeOption']); } finally { delete process.env.CDK_INTEG_ATMOSPHERE_POOL; }