Skip to content

fix(resolve-extends): always resolve extended parser presets for proper merging#4647

Merged
escapedcat merged 2 commits intoconventional-changelog:masterfrom
omar-y-abdi:fix/parser-preset-merge-with-issue-prefixes
Mar 13, 2026
Merged

fix(resolve-extends): always resolve extended parser presets for proper merging#4647
escapedcat merged 2 commits intoconventional-changelog:masterfrom
omar-y-abdi:fix/parser-preset-merge-with-issue-prefixes

Conversation

@omar-y-abdi
Copy link
Copy Markdown
Contributor

Problem

When a user provides a partial parserPreset override (e.g. only issuePrefixes), the extended config's string parser preset is skipped entirely. This means the preset's headerPattern is never resolved, so it gets lost during the config merge.

For example, with this config:

module.exports = {
  extends: ['@commitlint/config-conventional'],
  parserPreset: {
    parserOpts: {
      issuePrefixes: ['PROJ-'],
    },
  },
};

The headerPattern from conventional-changelog-conventionalcommits (which includes !? for breaking changes) is never loaded. The parser falls back to conventional-changelog-angular's pattern, which lacks !?, causing type detection to fail on commits like feat!: breaking change.

Root cause

Two issues:

  1. loadExtends in @commitlint/resolve-extends had a !context.parserPreset guard that skipped resolution of the extended config's string parserPreset whenever the user provided any parserPreset object. This prevented the extended preset's headerPattern from being loaded.

  2. loadParserOpts in @commitlint/load unwrapped nested parserOpts.parserOpts by replacing the outer object entirely, dropping any user-provided properties (like issuePrefixes) that were merged at the outer level during config resolution.

Fix

  1. Remove the !context.parserPreset guard so string parser presets from extended configs are always resolved, making their parserOpts available for merging.

  2. During the nested parserOpts unwrapping, preserve outer properties by merging them into the inner object instead of discarding them.

Testing

  • Added unit test in @commitlint/resolve-extends verifying partial user parserPreset merges with extended config's preset
  • Added integration test in @commitlint/load with a fixture that reproduces the exact scenario from the issue
  • All existing tests pass (1161 passed, 3 pre-existing CLI failures unrelated to this change)

Fixes #4640

…er merging

When a user provides a partial parserPreset (e.g. only issuePrefixes),
the extended config's string parser preset was skipped because
loadExtends checked !context.parserPreset before resolving. This meant
the extended preset's headerPattern was never loaded, so it got lost
during the config merge.

Two changes fix this:

1. Remove the !context.parserPreset guard in loadExtends so string
   parser presets from extended configs are always resolved, making
   their parserOpts available for merging.

2. Preserve user-provided properties during the nested parserOpts
   unwrapping in loadParserOpts. Previously the unwrap replaced the
   outer object entirely, dropping any user additions that were merged
   at that level during config resolution.

Fixes conventional-changelog#4640
@qodo-code-review
Copy link
Copy Markdown

Review Summary by Qodo

Fix parser preset merging with partial user overrides

🐞 Bug fix

Grey Divider

Walkthroughs

Description
• Fix parser preset merging when user provides partial overrides
• Always resolve extended string parser presets for proper configuration
• Preserve user properties during nested parserOpts unwrapping
• Add integration and unit tests for partial preset override scenario
Diagram
flowchart LR
  A["User Config<br/>partial parserPreset"] -->|merge| B["Extended Config<br/>string parserPreset"]
  B -->|resolve| C["loadExtends<br/>remove guard"]
  C -->|unwrap| D["loadParserOpts<br/>preserve properties"]
  D -->|result| E["Merged parserPreset<br/>with all properties"]
Loading

Grey Divider

File Changes

1. @commitlint/resolve-extends/src/index.ts 🐞 Bug fix +8/-8

Always resolve extended string parser presets

• Remove !context.parserPreset guard to always resolve string parser presets from extended configs
• Update comment to explain why extended presets must always be resolved for proper merging
• Fix assignment to use c.parserPreset instead of config.parserPreset for consistency
• Update type annotation to allow ParserPreset objects in addition to strings

@commitlint/resolve-extends/src/index.ts


2. @commitlint/load/src/utils/load-parser-opts.ts 🐞 Bug fix +5/-1

Preserve user properties during parserOpts unwrap

• Preserve user-provided properties during nested parserOpts unwrapping
• Use destructuring to extract inner parserOpts while keeping outer properties
• Merge inner and outer properties instead of replacing outer object entirely
• Add comment explaining the fix for issue #4640

@commitlint/load/src/utils/load-parser-opts.ts


3. @commitlint/resolve-extends/src/index.test.ts 🧪 Tests +43/-0

Add unit test for partial preset merge

• Add unit test for partial user parserPreset merging with extended preset
• Verify that user-provided issuePrefixes merge with extended headerPattern
• Test that all properties from both configs are present in result
• Reference issue #4640 in test comment

@commitlint/resolve-extends/src/index.test.ts


View more (4)
4. @commitlint/load/src/load.test.ts 🧪 Tests +15/-0

Add integration test for partial preset override

• Add integration test for partial user parserPreset override scenario
• Verify merged parserPreset contains headerPattern, headerCorrespondence, and issuePrefixes
• Test fixture reproduces exact issue from #4640
• Reference issue #4640 in test comment

@commitlint/load/src/load.test.ts


5. @commitlint/load/fixtures/parser-preset-partial-user-override/commitlint.config.js 🧪 Tests +8/-0

Add fixture config for partial override test

• Create fixture config that extends another config
• Provide partial parserPreset with only issuePrefixes override
• Demonstrates the scenario from issue #4640

@commitlint/load/fixtures/parser-preset-partial-user-override/commitlint.config.js


6. @commitlint/load/fixtures/parser-preset-partial-user-override/extended/index.js 🧪 Tests +3/-0

Add extended config fixture

• Create extended config that provides string parserPreset reference
• Points to conventional-changelog-custom module

@commitlint/load/fixtures/parser-preset-partial-user-override/extended/index.js


7. @commitlint/load/fixtures/parser-preset-partial-user-override/extended/conventional-changelog-custom.js 🧪 Tests +6/-0

Add custom parser preset fixture

• Create custom parser preset module with headerPattern and headerCorrespondence
• Provides the extended preset that should merge with user's issuePrefixes

@commitlint/load/fixtures/parser-preset-partial-user-override/extended/conventional-changelog-custom.js


Grey Divider

Qodo Logo

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented Mar 12, 2026

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider


Action required

1. ESM namespace mutation throw 🐞 Bug ⛯ Reliability
Description
loadExtends now assigns c.parserPreset = parserPreset after dynamicImport(), but
dynamicImport() can return the raw imported module namespace object when there is no truthy
default export. ESM module namespace objects are immutable, so this assignment can throw a
TypeError and break config resolution for certain extended configs.
Code

@commitlint/resolve-extends/src/index.ts[R153-156]

+		if (typeof c === "object" && typeof c.parserPreset === "string") {
			const resolvedParserPreset = resolveFrom(c.parserPreset, cwd);

			const parserPreset: ParserPreset = {
Evidence
dynamicImport() explicitly falls back to returning the full imported value (module namespace)
when it doesn’t find a truthy default export; loadExtends() then mutates the returned value
(c) by assignment to c.parserPreset. If the fallback path returns an ESM module namespace
object, that object is not writable/extensible, so the assignment can throw and stop loading
extends.

@commitlint/resolve-extends/src/index.ts[16-25]
@commitlint/resolve-extends/src/index.ts[142-163]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`@commitlint/resolve-extends` mutates `c` (the value returned from `dynamicImport`) by assigning `c.parserPreset = parserPreset`. When `dynamicImport()` falls back to returning the raw imported module object (e.g., an ESM module namespace object with no default export), this assignment can throw and abort config resolution.

### Issue Context
`dynamicImport()` returns `(default &amp;&amp; default) || imported`, so if `default` is missing or falsy it returns `imported`. `imported` may be an ESM module namespace object, which is not safely mutable.

### Fix Focus Areas
- @commitlint/resolve-extends/src/index.ts[142-168]

### Suggested fix
- Change the code to avoid mutating `c` directly:
 - Create `const cMutable = typeof c === &#x27;object&#x27; &amp;&amp; c !== null ? { ...(c as any) } : c;`
 - When resolving string `parserPreset`, assign to `cMutable.parserPreset` (or create `const nextConfig = { ...cMutable, parserPreset }`).
 - Ensure recursion and returned configs use the mutable/copied object (`loadExtends(nextConfig, ctx)` and push `nextConfig`) rather than the original imported value.
- Add (or adjust) a unit test where `dynamicImport` returns an object that simulates an ESM namespace (e.g., `Object.freeze({ parserPreset: &#x27;./preset&#x27; })`) to assert no throw.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

@codesandbox-ci
Copy link
Copy Markdown

codesandbox-ci Bot commented Mar 12, 2026

This pull request is automatically built and testable in CodeSandbox.

To see build info of the built libraries, click here or the icon next to each commit SHA.

Comment thread @commitlint/resolve-extends/src/index.ts
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes commitlint config resolution so that extended configs’ string parserPresets are always resolved and their parserOpts are preserved/merged when users provide partial parserPreset overrides (e.g. issuePrefixes), addressing #4640.

Changes:

  • Update @commitlint/resolve-extends to always resolve string parserPresets from extended configs so their parserOpts participate in merging.
  • Adjust @commitlint/load parser opts unwrapping to preserve outer merged properties when encountering nested parserOpts.parserOpts.
  • Add unit + integration tests and a fixture to reproduce the reported configuration scenario.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
@commitlint/resolve-extends/src/index.ts Always resolves string parserPreset in extended configs and assigns the resolved preset onto the extended config object.
@commitlint/resolve-extends/src/index.test.ts Adds a unit test intended to cover partial parserPreset merging behavior.
@commitlint/load/src/utils/load-parser-opts.ts Preserves outer parser options when unwrapping nested parserOpts.parserOpts.
@commitlint/load/src/load.test.ts Adds integration coverage validating merged parser preset options from an extended string preset + user partial override.
@commitlint/load/fixtures/parser-preset-partial-user-override/extended/index.js Fixture extended config exporting a string parserPreset.
@commitlint/load/fixtures/parser-preset-partial-user-override/extended/conventional-changelog-custom.js Fixture parser preset providing headerPattern/headerCorrespondence.
@commitlint/load/fixtures/parser-preset-partial-user-override/commitlint.config.js Fixture user config partially overriding parserPreset.parserOpts.issuePrefixes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment thread @commitlint/resolve-extends/src/index.test.ts
Comment thread @commitlint/load/src/utils/load-parser-opts.ts Outdated
@escapedcat
Copy link
Copy Markdown
Member

Please have a look at the feedback from qodo and copilot, thanks!

- Shallow-copy the dynamicImport result to avoid mutating an immutable
  ESM namespace object when assigning the resolved parserPreset.
- Add explicit type cast in load-parser-opts destructuring so inner
  is Record<string, unknown> instead of unknown.
- Clarify unit test scope: it covers the merge path while the
  integration test covers string-to-object resolution.
Copy link
Copy Markdown
Contributor Author

@omar-y-abdi omar-y-abdi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finito

@escapedcat escapedcat merged commit e9ef76c into conventional-changelog:master Mar 13, 2026
12 checks passed
@escapedcat
Copy link
Copy Markdown
Member

Thanks!

This was referenced Apr 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

fix: type detection issue when an exclamation mark is added to the commit (in case of custom issue prefixes)

3 participants