Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
105 changes: 99 additions & 6 deletions index.js
Original file line number Diff line number Diff line change
@@ -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 = '<type>(<scope>): <Subject>';
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: {
Expand All @@ -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 "<type>(<scope>): <subject>" 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;
51 changes: 47 additions & 4 deletions index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});