From ecb7b635cf351221964c56d0985c3458f7de1a33 Mon Sep 17 00:00:00 2001 From: Aaron Ware Date: Sat, 8 Aug 2026 19:02:48 -0400 Subject: [PATCH] chore(NO-TASK): Explain which part of a commit header failed The config carried a top-level messages key that is not a commitlint option, so none of it ever reached the user. What they saw instead was misleading: when headerPattern fails to match, the parser extracts nothing, so a misspelled type reported as type-empty and subject-empty - two errors that never mention the real problem. Diagnostics now come from a linchpin-header rule registered as a plugin, which is the only way a config can supply its own message text. It names the part that failed and suggests a fix for the common near-misses: a lowercase task key, NOTASK for NO-TASK, and a missing scope or subject. type-empty and subject-empty are silenced because they only ever fire as noise alongside the real diagnosis. nope(PROJ-123): Bad type here before: subject may not be empty / type may not be empty after: "nope" is not a valid type. Valid types: add, improve, ... --- README.md | 18 ++++++++- index.js | 105 +++++++++++++++++++++++++++++++++++++++++++++++--- index.test.js | 51 ++++++++++++++++++++++-- 3 files changed, 163 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index bfcd08b..68646ca 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,23 @@ docs(#42): Update readme | `type-enum` | error | Type must be one of: `add`, `improve`, `build`, `chore`, `ci`, `docs`, `feat`, `feature`, `fix`, `perf`, `refactor`, `remove`, `revert`, `style`, `test`, `update` | | `subject-case` | warning | Subject must be in sentence-case | -The config also sets a custom `parserPreset.parserOpts.headerPattern` that enforces the scope format, and overrides the `type-enum`, `subject-case` and `header-pattern` messages so failures explain the convention rather than printing the raw rule name. +The config also sets a custom `parserPreset.parserOpts.headerPattern` that enforces the scope format. + +### Failure messages + +commitlint on its own reports a malformed header badly. When `headerPattern` fails to match, the parser extracts nothing, so a misspelled type comes back as *"type may not be empty"* and *"subject may not be empty"* — neither of which is the actual problem. + +The `linchpin-header` rule replaces that with a description of the part that failed: + +``` +✖ "nope" is not a valid type. + Valid types: add, improve, build, chore, ci, docs, feat, ... + +✖ "proj-123" is not a valid scope. Task keys are uppercase - try "PROJ-123". + Use a task key such as PROJ-123, NO-TASK, or a GitHub issue number such as #42. +``` + +It also catches the common near-misses: a lowercase task key, `NOTASK` for `NO-TASK`, and a missing scope or subject. ### Ignored commits diff --git a/index.js b/index.js index a179262..b25ee6e 100644 --- a/index.js +++ b/index.js @@ -1,10 +1,106 @@ 'use strict'; +const TYPES = ['add', 'improve', 'build', 'chore', 'ci', 'docs', 'feat', 'feature', 'fix', 'perf', 'refactor', 'remove', 'revert', 'style', 'test', 'update']; + +// A ClickUp-style task key, NO-TASK, or a GitHub issue number. +const SCOPE = /^(?:[A-Z]+-\d+|NO-TASK|#\d+)$/; + +// Deliberately loose: this is what splits a header up so we can say which part is wrong. +// headerPattern below is the strict one, and it matching is the actual pass condition. +const LOOSE_HEADER = /^([^\s(!:]+)(?:\(([^)]*)\))?(!)?:[ \t]*(.*)$/; + +const FORMAT = '(): '; +const SCOPE_HELP = 'a task key such as PROJ-123, NO-TASK, or a GitHub issue number such as #42'; +const EXAMPLE = 'Example: feat(PROJ-123): Add new feature'; + +/** + * Explain what is wrong with a header, in the terms the author needs to fix it. + * + * commitlint on its own cannot do this. When headerPattern fails to match, the parser + * extracts nothing, so the built-in rules report "type may not be empty" and "subject may + * not be empty" - which is confusing when the real problem is a misspelled type or a scope + * that is not a task key. + * + * @param {string} header First line of the commit message. + * @returns {string|null} A description of every problem found, or null when the header is fine. + */ +function explain(header) { + if (!header || !header.trim()) { + return `Commit message is empty. Expected ${FORMAT}. ${EXAMPLE}`; + } + + const match = header.match(LOOSE_HEADER); + if (!match) { + return [ + `"${header}" is not in the form ${FORMAT}.`, + `A type, a scope in parentheses, then a colon and a space are all required.`, + EXAMPLE, + ].join('\n'); + } + + const [, type, scope, , subject] = match; + const problems = []; + + if (!TYPES.includes(type)) { + const near = TYPES.filter((t) => t.startsWith(type.slice(0, 2)) || type.startsWith(t.slice(0, 2))); + problems.push( + `"${type}" is not a valid type.${near.length ? ` Did you mean ${near.map((t) => `"${t}"`).join(' or ')}?` : ''}` + + `\n Valid types: ${TYPES.join(', ')}` + ); + } + + if (scope === undefined) { + problems.push(`The scope is missing. Put one in parentheses after the type - use ${SCOPE_HELP}.`); + } else if (!SCOPE.test(scope)) { + const hint = /^no.?task$/i.test(scope) + ? ' Write it exactly as NO-TASK.' + : /^[a-z]+-\d+$/.test(scope) + ? ` Task keys are uppercase - try "${scope.toUpperCase()}".` + : ''; + problems.push(`"${scope}" is not a valid scope.${hint}\n Use ${SCOPE_HELP}.`); + } + + if (!subject.trim()) { + problems.push('The subject is missing. Describe the change after the colon.'); + } + + // The strict pattern stops at the first character it cannot take, so a header can look + // fine yet parse to a truncated subject. Worth saying out loud rather than silently + // shipping a half-sentence into the changelog. + if (problems.length === 0 && subject.trim()) { + const usable = subject.match(/^[\w\d\s,-]*/)[0].trim(); + if (!usable) { + problems.push(`The subject must start with a letter or number. "${subject}" does not.`); + } + } + + return problems.length ? problems.join('\n') : null; +} + module.exports = { extends: ['@commitlint/config-conventional'], + plugins: [ + { + rules: { + // Owns all header diagnostics. Registered as a plugin because that is the only + // way commitlint lets a config supply its own message text - the top-level + // `messages` key some configs carry is not a commitlint option and does nothing. + 'linchpin-header': (parsed) => { + const problem = explain(parsed.header || ''); + return [problem === null, problem || '']; + }, + }, + }, + ], rules: { - 'type-enum': [2, 'always', ['add', 'improve', 'build', 'chore', 'ci', 'docs', 'feat', 'feature', 'fix', 'perf', 'refactor', 'remove', 'revert', 'style', 'test', 'update']], + 'linchpin-header': [2, 'always'], + 'type-enum': [2, 'always', TYPES], 'subject-case': [1, 'always', ['sentence-case']], + // Silenced because they fire whenever headerPattern fails to match, reporting an empty + // type and subject regardless of the real cause. linchpin-header covers both cases and + // says which part is actually wrong. + 'type-empty': [0, 'never'], + 'subject-empty': [0, 'never'], }, parserPreset: { parserOpts: { @@ -18,9 +114,6 @@ module.exports = { // Matches `chore(main): release 1.2.3`, and the master / component variants. ignores: [(message) => /^chore\(.+\): release v?\d+\.\d+\.\d+/.test(message)], helpUrl: 'https://www.conventionalcommits.org', - messages: { - 'type-enum': 'Commit type must be one of: add, improve, build, chore, ci, docs, feat, feature, fix, perf, refactor, remove, revert, style, test, update.', - 'subject-case': 'Commit message subject must be in sentence-case.', - 'header-pattern': 'Commit message must match the pattern "(): " where scope is a ClickUp-style task key (e.g. PROJ-123), NO-TASK, or a GitHub issue number (e.g. #42).', - }, }; + +module.exports.explain = explain; diff --git a/index.test.js b/index.test.js index fd0ef3f..4af7ae8 100644 --- a/index.test.js +++ b/index.test.js @@ -81,9 +81,52 @@ describe('@linchpinagency/commitlint-config', () => { expect(config.helpUrl).toBe('https://www.conventionalcommits.org'); }); - test('messages are defined', () => { - expect(config.messages['type-enum']).toBeDefined(); - expect(config.messages['subject-case']).toBeDefined(); - expect(config.messages['header-pattern']).toBeDefined(); + // The previous config carried a top-level `messages` key. That is not a commitlint + // option and never reached the user; diagnostics now come from the linchpin-header rule. + test('does not rely on a non-existent messages option', () => { + expect(config.messages).toBeUndefined(); + }); + + describe('linchpin-header explains which part failed', () => { + const { explain } = config; + + test.each([ + ['feat(PROJ-123): Add new feature'], + ['fix(NO-TASK): Fix a bug'], + ['docs(#42): Update readme'], + ['build(NO-TASK): Update npm dependency npm-run-all2 to v9.0.3'], + ])('accepts %s', (header) => { + expect(explain(header)).toBeNull(); + }); + + test('names the offending type, not an empty subject', () => { + const out = explain('nope(PROJ-123): Bad type here'); + expect(out).toContain('"nope" is not a valid type'); + expect(out).not.toContain('subject may not be empty'); + }); + + test('names the offending scope', () => { + expect(explain('feat(deps): Update something')).toContain('"deps" is not a valid scope'); + }); + + test('suggests the uppercase form of a lowercase task key', () => { + expect(explain('feat(proj-123): Lowercase key')).toContain('try "PROJ-123"'); + }); + + test('catches the NOTASK typo specifically', () => { + expect(explain('feat(NOTASK): Typo scope')).toContain('exactly as NO-TASK'); + }); + + test('reports a missing scope', () => { + expect(explain('feat: No scope at all')).toContain('scope is missing'); + }); + + test('reports a missing subject', () => { + expect(explain('feat(NO-TASK):')).toContain('subject is missing'); + }); + + test('reports an empty header', () => { + expect(explain('')).toContain('empty'); + }); }); });