conventional-changelog / commitlint

📓 Lint commit messages
https://commitlint.js.org
MIT License
16.64k stars 892 forks source link

parserPreset is overwritten when extending multiple configurations #3257

Open voxmachinaio opened 2 years ago

voxmachinaio commented 2 years ago

I am attempting to replace the default headerPattern and headerCorrespondence values of @commitlint/config-conventional by using a plugin module.

My plugin module looks like this:

index.js

module.exports = {
  parserPreset: {
    parserOpts: {
      headerPattern: /^(?:([A-Z]{2,}-\d+(?:, [A-Z]{2,}-\d+)*) \| ){1}(\w*)(?:\((\w*)\))?!?: (.+)$/,
      headerCorrespondence: [`jiraIds`, `type`, `scope`, `subject`],
    },
  },
  rules: {
    'header-max-length': [2, `always`, 110],
    'jira-empty': [2, `always`],
  },
  plugins: [
    {
      rules: {
        'jira-empty': ({ jiraIds }) => {
          const test = jiraIds ? true : false;
          return [
            test,
            `jira id(s) may not be empty`,
          ]
        },
      }
    }
  ]
}

package.json

{
  "name": "@acse/commitlint-config",
  "version": "0.4.0",
  "description": "ACSE Commitlint Config",
  "files": [
    "index.js"
  ],
  "keywords": [
    "acse",
    "commitlint"
  ],
  "peerDependencies": {
    "@commitlint/cli": "^17.0.3",
    "@commitlint/config-conventional": "^17.0.3"
  }
}

Expected Behavior

From a test git repository with @commitlint/cli, @commitlint/config-conventional, and @acse/commitlint-config (the plugin module) installed locally, run:

echo 'JIRA-111, JIRA-222 | feat(scope): subject' | ./node_modules/.bin/commitlint -x @commitlint/config-conventional @acse/commitlint-config

My expectation would be that this command exits in success (since that header line passes the headerPattern regex set in the plugin module's config.

Current Behavior

Instead that command exits in failure because the parserPreset in the resolved config has been overwritten by the parserPreset that @commitlint/config-conventional sets (note the addition of --print-config):

# echo 'JIRA-111, JIRA-2222 | feat(scope): subject' | ./node_modules/.bin/commitlint -x @commitlint/config-conventional @acse/commitlint-config --print-config

{
  extends: [ '@commitlint/config-conventional', '@acse/commitlint-config' ],
  formatter: '/home/voxmachina/dev/test/test/node_modules/@commitlint/format/lib/index.js',
  parserPreset: {
    parserOpts: {
      headerPattern: /^(\w*)(?:\((.*)\))?!?: (.*)$/,
      breakingHeaderPattern: /^(\w*)(?:\((.*)\))?!: (.*)$/,
      headerCorrespondence: [ 'type', 'scope', 'subject' ],
      noteKeywords: [ 'BREAKING CHANGE', 'BREAKING-CHANGE' ],
      revertPattern: /^(?:Revert|revert:)\s"?([\s\S]+?)"?\s*This reverts commit (\w*)\./i,
      revertCorrespondence: [ 'header', 'hash' ],
      issuePrefixes: [ '#' ]
    },
    name: 'conventional-changelog-conventionalcommits',
    path: './node_modules/conventional-changelog-conventionalcommits/index.js'
  },
  ignores: undefined,
  defaultIgnores: undefined,
  plugins: { local: { rules: { 'jira-empty': [Function: jira-empty] } } },
  rules: {
    'body-leading-blank': [ 1, 'always' ],
    'body-max-line-length': [ 2, 'always', 100 ],
    'footer-leading-blank': [ 1, 'always' ],
    'footer-max-line-length': [ 2, 'always', 100 ],
    'header-max-length': [ 2, 'always', 110 ],
    'subject-case': [
      2,
      'never',
      [ 'sentence-case', 'start-case', 'pascal-case', 'upper-case' ]
    ],
    'subject-empty': [ 2, 'never' ],
    'subject-full-stop': [ 2, 'never', '.' ],
    'type-case': [ 2, 'always', 'lower-case' ],
    'type-empty': [ 2, 'never' ],
    'type-enum': [
      2,
      'always',
      [
        'build',  'chore',
        'ci',     'docs',
        'feat',   'fix',
        'perf',   'refactor',
        'revert', 'style',
        'test'
      ]
    ],
    'jira-empty': [ 2, 'always' ]
  },
  helpUrl: 'https://github.com/conventional-changelog/commitlint/#what-is-commitlint',
  prompt: {
    questions: {
      type: {
        description: "Select the type of change that you're committing",
        enum: {
          feat: {
            description: 'A new feature',
            title: 'Features',
            emoji: '✨'
          },
          fix: { description: 'A bug fix', title: 'Bug Fixes', emoji: '🐛' },
          docs: {
            description: 'Documentation only changes',
            title: 'Documentation',
            emoji: '📚'
          },
          style: {
            description: 'Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)',
            title: 'Styles',
            emoji: '💎'
          },
          refactor: {
            description: 'A code change that neither fixes a bug nor adds a feature',
            title: 'Code Refactoring',
            emoji: '📦'
          },
          perf: {
            description: 'A code change that improves performance',
            title: 'Performance Improvements',
            emoji: '🚀'
          },
          test: {
            description: 'Adding missing tests or correcting existing tests',
            title: 'Tests',
            emoji: '🚨'
          },
          build: {
            description: 'Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)',
            title: 'Builds',
            emoji: '🛠'
          },
          ci: {
            description: 'Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs)',
            title: 'Continuous Integrations',
            emoji: '⚙️'
          },
          chore: {
            description: "Other changes that don't modify src or test files",
            title: 'Chores',
            emoji: '♻️'
          },
          revert: {
            description: 'Reverts a previous commit',
            title: 'Reverts',
            emoji: '🗑'
          }
        }
      },
      scope: {
        description: 'What is the scope of this change (e.g. component or file name)'
      },
      subject: {
        description: 'Write a short, imperative tense description of the change'
      },
      body: { description: 'Provide a longer description of the change' },
      isBreaking: { description: 'Are there any breaking changes?' },
      breakingBody: {
        description: 'A BREAKING CHANGE commit requires a body. Please enter a longer description of the commit itself'
      },
      breaking: { description: 'Describe the breaking changes' },
      isIssueAffected: { description: 'Does this change affect any open issues?' },
      issuesBody: {
        description: 'If issues are closed, the commit requires a body. Please enter a longer description of the commit itself'
      },
      issues: {
        description: 'Add issue references (e.g. "fix #123", "re #123".)'
      }
    }
  }
}

You can see that it does correctly merge the local plugin definition and additional rule from the plugin module, but it overwrites the parserPreset value entirely.

If I don't additionally extend the configuration with with @commitlint/config-conventional I do get the correct headerPattern and headerCorrespondence values, but then I lose everything else extending @commitlint/config-conventional gives me (all the default rules, prompt definitions, etc):

# echo 'JIRA-111, JIRA-222 | feat(scope): subject' | ./node_modules/.bin/commitlint -x @acse/commitlint-config --print-config

{
  extends: [ '@acse/commitlint-config' ],
  formatter: '/home/voxmachina/dev/test/test/node_modules/@commitlint/format/lib/index.js',
  parserPreset: {
    parserOpts: {
      headerPattern: /^(?:([A-Z]{2,}-\d+(?:, [A-Z]{2,}-\d+)*) \| ){1}(\w*)(?:\((\w*)\))?!?: (.+)$/,
      headerCorrespondence: [ 'jiraIds', 'type', 'scope', 'subject' ]
    }
  },
  ignores: undefined,
  defaultIgnores: undefined,
  plugins: { local: { rules: { 'jira-empty': [Function: jira-empty] } } },
  rules: {
    'header-max-length': [ 2, 'always', 110 ],
    'jira-empty': [ 2, 'always' ]
  },
  helpUrl: 'https://github.com/conventional-changelog/commitlint/#what-is-commitlint',
  prompt: {}
}

Affected packages

Possible Solution

Not sure, but my expectation based on reading the documentation and gut instinct would be that the parserPreset options are merged into the ones provided by packages extended "before" my plugin package (when defining the extensions via the -x option on the commitlint cli, i.e.:

Steps to Reproduce (for bugs)

  1. Create plugin module as detailed above
  2. Create test git repository that installs @commitlint/cli, @commitlint/config-conventional, and <plugin_module> locally
  3. Run the above commands, validating that the parserPreset gets overwritten

Context

Essentially I am attempting to "extend" the default rules and configuration of the @commitlint/config-conventional module with headerPattern and headerCorrespondence values that should inform the parser to parse out a superset of values that are expected from the header (jiraIds, type, scope, subject).

Your Environment

Executable Version
commitlint --version 17.0.3
git --version 2.25.1
node --version 16.14.2
voxmachinaio commented 2 years ago

Some things I've tried:

  1. Adding extends: ['@commitlint/config-conventional'] to the plugin module's exported configuration: the result is the same, parserPreset is overwritten
  2. Switching the order of "extension" in the commitlint cli (i.e. commitlint -x @acse/commitlint-config @commitlint/config-conventional): the result is the same, parserPreset is overwritten
escapedcat commented 2 years ago

Thanks for the issue and the research! I doubt we habe time to look into this but if you are motivated to dig into this we could try to support.

voxmachinaio commented 2 years ago

I was able to find a solution! I'm not sure what the technical mechanism was that was producing the issue but the fix was to factor out the parserPreset object into a separate file, i.e.:

instead of:

module.exports = {
  parserPreset: {
    parserOpts: {
      headerPattern: /^(?:([A-Z]{2,}-\d+(?:, [A-Z]{2,}-\d+)*) \| ){1}(\w*)(?:\((\w*)\))?!?: (.+)$/,
      headerCorrespondence: [`jiraIds`, `type`, `scope`, `subject`],
    },
  },
  rules: {
    'header-max-length': [2, `always`, 110],
    'jira-empty': [2, `always`],
  },
  plugins: [
    {
      rules: {
        'jira-empty': ({ jiraIds }) => {
          const test = jiraIds ? true : false;
          return [
            test,
            `jira id(s) may not be empty`,
          ]
        },
      }
    }
  ]
};

do this:

module.exports = {
  parserPreset: './parser-preset',
  rules: {
    'header-max-length': [2, `always`, 110],
    'jira-empty': [2, `always`],
  },
  plugins: [
    {
      rules: {
        'jira-empty': ({ jiraIds }) => {
          const test = jiraIds ? true : false;
          return [
            test,
            `jira id(s) may not be empty`,
          ]
        },
      }
    }
  ]
};

and in parser-preset.js

module.exports = {
  parserOpts: {
    headerPattern: /^(?:([A-Z]{2,}-\d+(?:, [A-Z]{2,}-\d+)*) \| ){1}(\w*)(?:\((\w*)\))?!?: (.+)$/,
    headerCorrespondence: [`jiraIds`, `type`, `scope`, `subject`],
  },
};

Notes from Debugging:

escapedcat commented 2 years ago

Awesome! Good to know, thanks! At least we have a workaround :)

driskell commented 1 year ago

I am finding this impossible. With the comment above the parseOpts ends up ignored as it's not within a parserPreset.

It seems the @commitlint/resolve-extends is unable to merge parserOpts because when you are referencing @commitlint/config-conventional it's now a function that is returned for parserOpts, which of course can't merge so it just overwrites.