Skip to content

feat(lint): allow for custom commit parser function#4829

Merged
escapedcat merged 2 commits into
conventional-changelog:masterfrom
esatterwhite:esatterwhite/custom-parser
Jun 16, 2026
Merged

feat(lint): allow for custom commit parser function#4829
escapedcat merged 2 commits into
conventional-changelog:masterfrom
esatterwhite:esatterwhite/custom-parser

Conversation

@esatterwhite

@esatterwhite esatterwhite commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

This change makes it possible to pass a custom function to the top level lint function, which is passed through to the parse function and consequently passed through to the parser exposed by convential-commits-parser. In linting it is important to parse content to be linted with a high level of fidelity so linting can be applied accurately. The default parser the is used, conventional-commits-parser makes many assumptions about the format of the commit to perform its parsing which leads to parsing errors and inaccuracies which in turn lead to many false positives in linting, and incorrect or invalid errors in additional confusing error messages.

This change will allow end users utilize a more accurate parser for their linting purposes in the short term while issues with the default parser are reconciled

Resolves: #4816

Description

Motivation and Context

Usage examples

// commitlint.config.js
module.exports = {
  parser: function(message, opts) {
    const parser = new MyParser(opts)
    const result = parser.parse(message)
    return {
      type: result.type
    , scope: result.scope
    , subject: result.subject
    , body: result.body
    , footer: result.footer
    , header: result.header
    , references: result.references
    }
  }
};
echo "your commit message here" | commitlint # fails/passes

How Has This Been Tested?

Added additional tests in the lint module passing a custom parser

Types of changes

allows a custom parse function to be passed in via lint through to > @commitlint/parse > conventional-commits-parser

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Checklist:

  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have added tests to cover my changes.
  • All new and existing tests passed.

@qodo-code-review

qodo-code-review Bot commented Jun 14, 2026

Copy link
Copy Markdown

Code Review by Qodo

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

Context used

Grey Divider


Action required

1. Custom parser crashes rules 🐞 Bug ≡ Correctness
Description
lint() now forwards opts.parser into @commitlint/parse, but the parsed commit object is not
normalized, so built-in rules like references-empty can throw when a custom parser omits required
fields (e.g., parsed.references). The new test custom parser works alongside parserOpts returns
no references while enabling references-empty, which should cause a runtime TypeError on
parsed.references.length.
Code

@commitlint/lint/src/lint.ts[40]

+			: await parse(message, opts.parser, opts.parserOpts);
Evidence
lint() now passes the user-supplied parser into @commitlint/parse, which returns the parser
output essentially unchanged (only sets raw). The references-empty rule unconditionally reads
parsed.references.length, so if a custom parser omits references (as the newly added lint test
does), linting will crash with a TypeError.

@commitlint/lint/src/lint.ts[36-43]
@commitlint/parse/src/index.ts[19-36]
@commitlint/rules/src/references-empty.ts[4-10]
@commitlint/lint/src/lint.test.ts[357-373]

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

### Issue description
`lint()` can now receive a custom parser via `opts.parser`, but `@commitlint/parse` returns the parser result largely as-is. Several built-in rules assume specific fields exist (e.g. `references-empty` assumes `parsed.references` is an array) and will crash if a custom parser omits them.

### Issue Context
- `lint()` calls `parse(message, opts.parser, opts.parserOpts)`.
- `@commitlint/parse` sets only `raw` on the returned object.
- `references-empty` dereferences `parsed.references.length` without guarding.

### Fix Focus Areas
- @commitlint/parse/src/index.ts[19-36]
- @commitlint/lint/src/lint.test.ts[357-376]
- @commitlint/rules/src/references-empty.ts[4-10]

### How to fix
1. In `@commitlint/parse/src/index.ts`, after calling the parser, normalize the returned object to ensure required fields are present with safe defaults before returning.
  - At minimum ensure: `references: []` when missing.
  - Strongly recommended: also default `mentions: []`, `notes: []`, and coerce `header/body/footer/type/scope/subject` to `null` when `undefined`.
  - Consider creating a new object (vs mutating the returned value) to avoid issues if a custom parser returns a frozen object.
2. Update the added lint test(s) to either:
  - include `references: []` in the custom parser return value, or
  - rely on the new normalization to keep the custom parser minimal.

(After normalization, rules like `references-empty` should never throw due to missing properties, even with partial custom parser outputs.)

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


Grey Divider

ⓘ You are approaching your monthly quota for Qodo. Upgrade your plan

Qodo Logo

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

feat(lint): allow custom commit parser function in lint options
✨ Enhancement 🧪 Tests 🕐 20-40 Minutes

Grey Divider

Walkthroughs

Description
• Add optional custom parser function to lint options
• Forward custom parser into @commitlint/parse for message parsing
• Add tests to verify rule evaluation uses custom parser output
Diagram
graph TD
  A["Consumer config"] --> B["lint(message, rules, opts)"] --> C["@commitlint/parse"] --> D{{"conventional-commits-parser"}} --> E["parsed commit object"] --> F["rules engine"]
  A --> G["custom parser (optional)"] --> C
  subgraph Legend
    direction LR
    _cfg["Config/Input"] ~~~ _mod["Module"] ~~~ _ext{{"External"}}
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Support a custom conventional-commits-parser instance/options only
  • ➕ Keeps parsing behavior closer to the upstream library contract
  • ➕ Less risk of custom parser returning partial/invalid shapes
  • ➖ Does not solve cases where upstream parsing assumptions are fundamentally wrong
  • ➖ Still blocks users needing non-conventional formats or higher-fidelity parsing
2. Introduce a commit AST schema + adapter layer
  • ➕ Clear contract for what rules can rely on (stronger than 'Parser' function)
  • ➕ Enables multiple parsers with consistent normalization
  • ➖ Much larger change; higher maintenance and migration cost
  • ➖ Requires broader ecosystem coordination (plugins/rules expecting current fields)
3. Add a dedicated 'parse' hook at rule-evaluation boundary
  • ➕ Allows overriding parsing without coupling to @commitlint/parse internals
  • ➕ Could support per-rule parsing strategies
  • ➖ More complex API surface; harder to reason about evaluation consistency
  • ➖ Likely more breaking/behavioral edge cases than a single parser override

Recommendation: The chosen approach (optional custom parser function passed through lint → parse) is the best incremental solution: it unblocks users immediately while preserving the existing default path. To keep this safe long-term, ensure the Parser type clearly documents required/optional fields (e.g., header/body/footer nullability) and consider validating/normalizing custom parser output before rules run.

Grey Divider

File Changes

Enhancement (2)
lint.ts Forward optional parser into parse() call +1/-1

Forward optional parser into parse() call

• Updates the lint parsing step to call parse(message, opts.parser, opts.parserOpts) rather than only passing parser options. This enables lint to use a user-provided parser function when configured.

@commitlint/lint/src/lint.ts


lint.ts Extend LintOptions with optional parser function +3/-0

Extend LintOptions with optional parser function

• Adds a new optional parser?: Parser field to LintOptions, documenting the ability to provide a custom parser for commit messages. Imports Parser type from the parse types module.

@commitlint/types/src/lint.ts


Tests (1)
lint.test.ts Add coverage for custom parser support in lint +66/-0

Add coverage for custom parser support in lint

• Adds new tests that pass a custom Parser into lint and assert it is used for parsing. Verifies both that lint validity reflects custom parser output and that rules evaluate against the custom-parsed fields.

@commitlint/lint/src/lint.test.ts


Grey Divider

ⓘ You are approaching your monthly quota for Qodo. Upgrade your plan

Qodo Logo

Comment thread @commitlint/lint/src/lint.ts
@esatterwhite esatterwhite force-pushed the esatterwhite/custom-parser branch 3 times, most recently from 47c6498 to 97c5cf3 Compare June 14, 2026 12:29
@escapedcat escapedcat requested a review from Copilot June 14, 2026 12:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR adds support for supplying a custom commit parser function to @commitlint/lint, passing it through to @commitlint/parse so commit messages can be parsed with higher fidelity before rule evaluation.

Changes:

  • Extend LintOptions to accept an optional parser function (typed via @commitlint/types).
  • Pass the custom parser from @commitlint/lint into @commitlint/parse.
  • Add documentation and tests covering custom parser usage.

Reviewed changes

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

File Description
docs/reference/configuration.md Documents configuring a custom parser function (plus related parser expectations).
@commitlint/types/src/lint.ts Adds parser?: Parser to LintOptions type.
@commitlint/lint/src/lint.ts Forwards opts.parser into parse(message, parser, parserOpts).
@commitlint/lint/src/lint.test.ts Adds tests validating that a provided custom parser affects lint results.

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

Comment thread docs/reference/configuration.md Outdated
Comment thread docs/reference/configuration.md Outdated
Comment thread @commitlint/types/src/lint.ts Outdated
Comment thread @commitlint/lint/src/lint.test.ts Outdated
@escapedcat

escapedcat commented Jun 14, 2026

Copy link
Copy Markdown
Member

Thanks, could you adjust this to multiple commits to follow a TDD approach?
i.e.

  1. commit only the tests, CI will be red
  2. commit the feature, CI will be green

Further feedback from Claude:

One thing to make that meaningful: right now only the third test actually fails without the implementation. The first two pass even with the default parser, because "any message" already yields an empty type, so type-enum/type-empty give the same result with or without the change. Worth reworking them to assert on a field where the custom and default parsers genuinely differ, so they go red without the parser pass-through.

Docs

  • The documented config path doesn't actually work: the new section shows parserPreset: { parser: ... }, but the CLI only reads parserPreset.parserOpts (selectParserOpts, cli.ts:532) and never threads a parser into lint(). So that config silently does nothing. The feature is programmatic-only right now — lint(message, rules, { parser }) — so the docs should show that usage instead.
  • There's a duplicate ### Common parserOpts heading added right above the existing one (the first ends up empty).

Tests / types

  • custom parser works alongside parserOpts doesn't pass any parserOpts — only { parser }. Either add one to actually cover the combination, or rename it.
  • The as unknown as ReturnType<Parser> cast is needed only because Parser's return type (Omit<Commit, "raw">) collapses to roughly { [k: string]: string | null } via the CommitMeta index signature on Commit — which is also why partial objects compile. Not something you introduced, but it means the type won't enforce
    notes/mentions/references, which is exactly why omitting those arrays crashes references-empty at runtime. Could be worth a follow-up to tighten the type and/or default those arrays to [] after a custom parse.
  • Nit: import { Parser }import type { Parser } in types/src/lint.ts for consistency.

Please also check if the feedback from copilot is valid and should be tackled or comment on it

@esatterwhite

esatterwhite commented Jun 14, 2026

Copy link
Copy Markdown
Contributor Author

Please also check if the feedback from copilot is valid and should be tackled or comment on it

😕 do you think it should be tackled as the maintainer?

Given that typescript has no runtime benefit, it makes no difference to me

@escapedcat

Copy link
Copy Markdown
Member

Please adjust the comments in TDD way first.

Regarding the AI feedback. I don't want you to tackle every one of them. I first want you to evaluate and either fix or comment on each why it's not worth it/needed.

Once we have this out of the way I take a look.

@esatterwhite esatterwhite force-pushed the esatterwhite/custom-parser branch from 97c5cf3 to d207ae9 Compare June 14, 2026 23:53
@escapedcat

Copy link
Copy Markdown
Member

We first need the test commit to fail the CI then you can push the "fix"

@esatterwhite esatterwhite force-pushed the esatterwhite/custom-parser branch 2 times, most recently from 8d4e2bd to b94fa9d Compare June 15, 2026 11:55
@escapedcat

Copy link
Copy Markdown
Member

Is this the expected error?:

Run pnpm build
$ tsc -b
Error: @commitlint/lint/src/lint.test.ts(328,5): error TS2353: Object literal may only specify known properties, and 'parser' does not exist in type 'LintOptions'.
Error: @commitlint/lint/src/lint.test.ts(350,5): error TS2353: Object literal may only specify known properties, and 'parser' does not exist in type 'LintOptions'.
Error: @commitlint/lint/src/lint.test.ts(384,5): error TS2353: Object literal may only specify known properties, and 'parser' does not exist in type 'LintOptions'.
[ELIFECYCLE] Command failed with exit code 2.
Error: Process completed with exit code 2.

@esatterwhite

Copy link
Copy Markdown
Contributor Author

Is this the expected error?:

Run pnpm build
$ tsc -b
Error: @commitlint/lint/src/lint.test.ts(328,5): error TS2353: Object literal may only specify known properties, and 'parser' does not exist in type 'LintOptions'.
Error: @commitlint/lint/src/lint.test.ts(350,5): error TS2353: Object literal may only specify known properties, and 'parser' does not exist in type 'LintOptions'.
Error: @commitlint/lint/src/lint.test.ts(384,5): error TS2353: Object literal may only specify known properties, and 'parser' does not exist in type 'LintOptions'.
[ELIFECYCLE] Command failed with exit code 2.
Error: Process completed with exit code 2.

At this point, yes it would be. just added the tests. no implementation changes

@escapedcat

Copy link
Copy Markdown
Member

Can you the if the build works with adding just the type signature parser?: Parser to LintOptions and then the actual test(s) fail?

  1. @commitlint/types/src/lint.ts — add the parser option to the type (this alone fixes the TS2353 build break):
  @@ -1,4 +1,5 @@
   import type { ParserOptions as Options } from "conventional-commits-parser";
   import { IsIgnoredOptions } from "./is-ignored.js";
   import { PluginRecords } from "./load.js";
  +import type { Parser } from "./parse.js";
   import { RuleConfigSeverity, RuleConfigTuple } from "./rules.js";
  @@ -14,7 +15,9 @@ export interface LintOptions {
        /** Additional commits to ignore, defined by ignore matchers  */
        ignores?: IsIgnoredOptions["ignores"];
        /** The parser configuration to use when linting the commit */
        parserOpts?: Options;
  +     /** A custom parser function, used instead of the default conventional-commits-parser */
  +     parser?: Parser;

        plugins?: PluginRecords;
        helpUrl?: string;
   }
  1. @commitlint/lint/src/lint.ts — pass the option through (line 40):
  @@ -36,7 +36,7 @@ export default async function lint(
        // Parse the commit message
        const parsed =
                message === ""
                        ? { header: null, body: null, footer: null }
  -                     : await parse(message, undefined, opts.parserOpts);
  +                     : await parse(message, opts.parser, opts.parserOpts);

@esatterwhite

Copy link
Copy Markdown
Contributor Author

@commitlint/lint/src/lint.ts — pass the option through (line 40):

That is the only code change. So it would kind of defeat the purpose of having two commits

@escapedcat

Copy link
Copy Markdown
Member

If you don't mind I can give it a try tomorrow. Would be nice to achieve a state where we have failing tests without your later fix.

@esatterwhite

Copy link
Copy Markdown
Contributor Author

I don't mind at all.

To me this isn't really a fix to anything - its a new feature. proving that it doesn't work seems odd to me

@escapedcat escapedcat force-pushed the esatterwhite/custom-parser branch from b94fa9d to 1d4963d Compare June 16, 2026 09:24
@escapedcat

escapedcat commented Jun 16, 2026

Copy link
Copy Markdown
Member

Adjusted minimal and force-pushed. Build is green and tests are running. Would be nice to see all 3 tests failing.

Further feedback in cooperation with Claude:

I added only the parser?: Parser type signature to LintOptions so your tests compile and run — no implementation yet (the lint()parse() pass-through isn't there). Result: 2 pass, 1 fail; only custom parser receives parser options… fails.

Note the type signature is compile-only, so it can't change the results — the custom parser is never actually called yet. That's exactly why receives parser options fails (the parser never runs → receivedOpts stays undefined), and if you make the parsers in the other two tests throw, those two still pass — so they aren't invoking the parser at all. They pass because they lint "any message", which the default parser also reads as an empty type, so type-enum/type-empty give the same answer with or without a custom parser.

For a proper failing-tests commit, all three should fail without the implementation — that's what proves each test actually depends on the feature. Could you rework those two so they assert on something only a custom parser would produce (a type/scope/subject the default parser wouldn't return for the same input)? Once all three are red, your follow-up implementation commit turning them green will show the feature genuinely works.

@esatterwhite

Copy link
Copy Markdown
Contributor Author

what is it that we're trying to accomplish here?

This adds tests to confirm that functionality for conventional-changelog#4816
doesn't exist and doesn't work
This change makes it possible to pass a custom function to the top level
lint function, which is passed through to the parse function and
consequently passed through to the parser exposed by
convential-commits-parser. In linting it is important to parse content
to be linted with a high level of fidelity so linting can be applied
accurately. The default parser the is used, conventional-commits-parser
makes many assumptions about the format of the commit to perform its
parsing which leads to parsing errors and inaccuracies which in turn
lead to many false positives in linting, and incorrect or invalid errors
in additional confusing error messages.

This change will allow end users utilize a more accurate parser for
their linting purposes in the short term while issues with the default
parser are reconciled

Resolves: conventional-changelog#4816
@escapedcat escapedcat force-pushed the esatterwhite/custom-parser branch from e35f798 to 7c6fa59 Compare June 16, 2026 16:08
@escapedcat

Copy link
Copy Markdown
Member

In cooperation with Claude:

  • Reworked the two tests that passed without the implementation. They linted "any message", which the default parser also resolves to an empty type, so type-enum/type-empty returned the same result whether or not a custom parser ran. They now assert on output only a custom parser would produce (a type the default parser wouldn't return for the same input).
  • Why: a test that still passes once the implementation is removed isn't covering the feature — those two passed even though the custom parser was never called.
  • Kept the implementation commit (e8a35e2a) unchanged.
  • Split as red → green: with the original tests, only 1 of the 3 failed without the implementation — the other two passed even with no feat in place. After the rework, the test commit (7c6fa59b) is fully red on its own (CI showed all 3 failing), and the implementation commit turns them green.
  • Result: all three tests now fail without the implementation and pass with it; CI is green.

@escapedcat escapedcat merged commit e820753 into conventional-changelog:master Jun 16, 2026
11 checks passed
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.

feat: Allow for custom commit parser in lint package

3 participants