From 1b48b00c1dc2bcb13acca2c5d2d510a9eca1b24d Mon Sep 17 00:00:00 2001 From: Yarchik Date: Thu, 23 Jul 2026 15:36:22 +0100 Subject: [PATCH] fix: detect a negated option's positive pair by attribute, not flag string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When only a negated option is defined, Commander gives it an implicit `true` default; when a positive counterpart is also defined, it must not (the pair shares one value). That "is there a positive counterpart?" check reconstructed the `--foo` long flag from `--no-foo` and looked it up by flag string, so it missed a positive option spelled differently but sharing the same attribute — a short-only `-c`, or `--fooBar` paired with `--no-foo-bar`. Those `--no-*` options were then wrongly defaulted to `true` when unused instead of left `undefined`. Match the positive counterpart by `attributeName()`, the same way the internal `DualOptions` helper already pairs them, so any spelling of the positive option is recognised. A genuinely lone `--no-*` option is unaffected and still defaults to `true`. --- lib/command.js | 20 ++++++++++++-------- tests/options.bool.test.js | 23 +++++++++++++++++++++++ 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/lib/command.js b/lib/command.js index 9a3d03e7d..0ebeddb8e 100644 --- a/lib/command.js +++ b/lib/command.js @@ -1128,14 +1128,18 @@ Expecting one of '${allowedValues.join("', '")}'`); this.getOptionValue(option.attributeName()) === undefined, ) .forEach((option) => { - // check for lone negated option: --no-foo without a --foo option - const positiveLongFlag = option.long.replace(/^--no-/, '--'); - if (!this._findOption(positiveLongFlag)) { - this.setOptionValueWithSource( - option.attributeName(), - true, - 'default', - ); + // Check for lone negated option: a --no-foo without any positive + // option sharing its attribute. Matching by attribute (like + // DualOptions) rather than by reconstructing the --foo flag string + // also recognises a differently-spelled positive such as -f or + // --fooBar as the pair, so it is not wrongly treated as lone. + const attributeName = option.attributeName(); + const hasPositiveOption = this.options.some( + (candidate) => + !candidate.negate && candidate.attributeName() === attributeName, + ); + if (!hasPositiveOption) { + this.setOptionValueWithSource(attributeName, true, 'default'); } }); diff --git a/tests/options.bool.test.js b/tests/options.bool.test.js index 0b24f2293..aa80e87d4 100644 --- a/tests/options.bool.test.js +++ b/tests/options.bool.test.js @@ -120,6 +120,29 @@ describe('boolean option with non-boolean default', () => { }); }); +describe('negated option paired with a differently-spelled positive', () => { + test('when short-only positive and negated then no implicit default', () => { + const program = new commander.Command(); + program.option('-c', 'add cheese').option('--no-c', 'remove cheese'); + program.parse([], { from: 'user' }); + assert.equal(program.opts().c, undefined); + }); + + test('when camelCase positive and kebab-case negated then no implicit default', () => { + const program = new commander.Command(); + program.option('--fooBar').option('--no-foo-bar'); + program.parse([], { from: 'user' }); + assert.equal(program.opts().fooBar, undefined); + }); + + test('when only negated (lone) then implicit default is still true', () => { + const program = new commander.Command(); + program.option('--no-sauce'); + program.parse([], { from: 'user' }); + assert.equal(program.opts().sauce, true); + }); +}); + // Regression test for #1301 with `-no-` in middle of option describe('regression test for -no- in middle of option name', () => { test('when option not specified then value is undefined', () => {