From e1754c0ec564eceb181706519a59abb4665ed01a Mon Sep 17 00:00:00 2001 From: Matej Voboril Date: Fri, 3 Jul 2026 19:28:16 -0500 Subject: [PATCH] fix: treat null & undefined as no-scope markers omit from error messages --- __tests__/plugin.test.js | 32 ++++++++++++++++++++++++++++++++ index.js | 37 ++++++++++++++++++++++++++----------- 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/__tests__/plugin.test.js b/__tests__/plugin.test.js index 7bbfa44..4dd39d9 100644 --- a/__tests__/plugin.test.js +++ b/__tests__/plugin.test.js @@ -136,6 +136,38 @@ describe('commitlintPluginSelectiveScope', () => { ).toBe(true) }) + // @commitlint/load uses es-toolkit merge, which turns config `[null]` into `[undefined]`. + it('should treat undefined in the allowed list as a no-scope marker (commitlint load)', () => { + expect( + commitlintPluginSelectiveScopeResolverWrapped( + { scope: null, type: 'ci' }, + { ci: [undefined] } + )[0] + ).toBe(true) + + expect( + commitlintPluginSelectiveScopeResolverWrapped( + { scope: undefined, type: 'ci' }, + { ci: [undefined] } + )[0] + ).toBe(true) + + expect( + commitlintPluginSelectiveScopeResolverWrapped( + { scope: null, type: 'ci' }, + { ci: [undefined, 'codebuild'] } + )[0] + ).toBe(true) + + const [ok, message] = commitlintPluginSelectiveScopeResolverWrapped( + { scope: 'github', type: 'ci' }, + { ci: [undefined, 'codebuild'] } + ) + expect(ok).toBe(false) + expect(message).not.toContain('"undefined"') + expect(message).toContain('"codebuild"') + }) + it('should match scope if provided', () => { expect( commitlintPluginSelectiveScopeResolverWrapped( diff --git a/index.js b/index.js index 8e0604f..373c55b 100644 --- a/index.js +++ b/index.js @@ -8,6 +8,19 @@ function normalizeScope(scope) { return scope } +// @commitlint/load merges config with es-toolkit, which turns array `null` +// entries into `undefined`. Treat both as "no scope" markers. +function isNoScopeMarker(value) { + return value == null +} + +function formatAllowedScopes(allowedScopes) { + return allowedScopes + .filter(s => !isNoScopeMarker(s)) + .map(s => `"${s}"`) + .join(', ') +} + module.exports = { rules: { 'selective-scope': (ctx, applicable, rule) => { @@ -24,7 +37,7 @@ module.exports = { } if (Array.isArray(allowedScopes)) { - // If the type maps to an empty array in the rule config, scope it not allowed + // If the type maps to an empty array in the rule config, scope is not allowed if (allowedScopes.length === 0) { if (scope != null) { return [ @@ -36,9 +49,13 @@ module.exports = { return [true] } - // Otherwise attempt to match against either null, a string literal, or a RegExp + // Otherwise attempt to match against either null/undefined, a string literal, or a RegExp if ( allowedScopes.findIndex(s => { + if (isNoScopeMarker(s)) { + return scope == null + } + if ( typeof scope === 'string' && Object.prototype.toString.call(s) === '[object RegExp]' @@ -46,29 +63,27 @@ module.exports = { return scope.match(s) } - // Equality comparison works for both strings and null return s === scope }) !== -1 ) { return [true] - } else if (allowedScopes.includes(null)) { + } else if (allowedScopes.some(isNoScopeMarker)) { return [ false, `commit message with type "${ ctx.type - }" may specify a scope, but if specified, it must be one of the following: ${allowedScopes - .filter(s => s !== null) - .map(s => `"${s}"`) - .join(', ')}` + }" may specify a scope, but if specified, it must be one of the following: ${formatAllowedScopes( + allowedScopes + )}` ] } else { return [ false, `commit message with type "${ ctx.type - }" must specify one of the following scopes: ${allowedScopes - .map(s => `"${s}"`) - .join(', ')}` + }" must specify one of the following scopes: ${formatAllowedScopes( + allowedScopes + )}` ] } }