From 5b6083687eb8f345b7268a76531bde46c64736cf Mon Sep 17 00:00:00 2001 From: Curly Date: Thu, 1 Apr 2021 17:25:26 +0800 Subject: [PATCH 01/12] feat(cz-commitlint): finish basic features inspired by cz-conventional-changelog --- @commitlint/cz-commitlint/.gitignore | 1 + @commitlint/cz-commitlint/README.md | 0 @commitlint/cz-commitlint/license.md | 19 ++ @commitlint/cz-commitlint/package.json | 40 +++ @commitlint/cz-commitlint/src/Prompter.ts | 320 ++++++++++++++++++ @commitlint/cz-commitlint/src/Question.ts | 191 +++++++++++ .../cz-commitlint/src/defaultSettings.ts | 106 ++++++ @commitlint/cz-commitlint/src/index.ts | 17 + @commitlint/cz-commitlint/src/types.ts | 43 +++ .../cz-commitlint/src/utils/case-fn.test.ts | 115 +++++++ .../cz-commitlint/src/utils/case-fn.ts | 54 +++ .../cz-commitlint/src/utils/full-stop-fn.ts | 34 ++ .../src/utils/leading-blank-fn.ts | 26 ++ .../cz-commitlint/src/utils/rules.test.ts | 119 +++++++ @commitlint/cz-commitlint/src/utils/rules.ts | 89 +++++ @commitlint/cz-commitlint/tsconfig.json | 11 + tsconfig.json | 3 +- yarn.lock | 215 +++--------- 18 files changed, 1234 insertions(+), 169 deletions(-) create mode 100644 @commitlint/cz-commitlint/.gitignore create mode 100644 @commitlint/cz-commitlint/README.md create mode 100644 @commitlint/cz-commitlint/license.md create mode 100644 @commitlint/cz-commitlint/package.json create mode 100644 @commitlint/cz-commitlint/src/Prompter.ts create mode 100644 @commitlint/cz-commitlint/src/Question.ts create mode 100644 @commitlint/cz-commitlint/src/defaultSettings.ts create mode 100644 @commitlint/cz-commitlint/src/index.ts create mode 100644 @commitlint/cz-commitlint/src/types.ts create mode 100644 @commitlint/cz-commitlint/src/utils/case-fn.test.ts create mode 100644 @commitlint/cz-commitlint/src/utils/case-fn.ts create mode 100644 @commitlint/cz-commitlint/src/utils/full-stop-fn.ts create mode 100644 @commitlint/cz-commitlint/src/utils/leading-blank-fn.ts create mode 100644 @commitlint/cz-commitlint/src/utils/rules.test.ts create mode 100644 @commitlint/cz-commitlint/src/utils/rules.ts create mode 100644 @commitlint/cz-commitlint/tsconfig.json diff --git a/@commitlint/cz-commitlint/.gitignore b/@commitlint/cz-commitlint/.gitignore new file mode 100644 index 0000000000..722d5e71d9 --- /dev/null +++ b/@commitlint/cz-commitlint/.gitignore @@ -0,0 +1 @@ +.vscode diff --git a/@commitlint/cz-commitlint/README.md b/@commitlint/cz-commitlint/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/@commitlint/cz-commitlint/license.md b/@commitlint/cz-commitlint/license.md new file mode 100644 index 0000000000..d13cc4b26a --- /dev/null +++ b/@commitlint/cz-commitlint/license.md @@ -0,0 +1,19 @@ +The MIT License (MIT) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/@commitlint/cz-commitlint/package.json b/@commitlint/cz-commitlint/package.json new file mode 100644 index 0000000000..acf5b91d4f --- /dev/null +++ b/@commitlint/cz-commitlint/package.json @@ -0,0 +1,40 @@ +{ + "name": "cz-conventional-changelog", + "version": "1.0.0", + "description": "Commitizen adapter using the commitlint.config.js", + "main": "./lib/index.js", + "scripts": { + "commit": "git-cz" + }, + "homepage": "https://github.com/conventional-changelog/commitlint#readme", + "repository": { + "type": "git", + "url": "https://github.com/conventional-changelog/commitlint.git" + }, + "engineStrict": true, + "engines": { + "node": ">= 10" + }, + "author": "Curly Brackets ", + "license": "MIT", + "config": { + "commitizen": { + "path": "./@commitlint/cz-commitlint" + } + }, + "dependencies": { + "@commitlint/load": "^12.0.1", + "@commitlint/types": "^12.0.1", + "chalk": "^4.1.0", + "inquirer-autocomplete-prompt": "^1.3.0", + "lodash": "^4.17.21", + "word-wrap": "^1.2.3" + }, + "devDependencies": { + "@types/inquirer": "^7.3.1" + }, + "peerDependencies": { + "commitizen": "^4.0.3", + "inquirer": "^8.0.0" + } +} diff --git a/@commitlint/cz-commitlint/src/Prompter.ts b/@commitlint/cz-commitlint/src/Prompter.ts new file mode 100644 index 0000000000..30eaf3b20d --- /dev/null +++ b/@commitlint/cz-commitlint/src/Prompter.ts @@ -0,0 +1,320 @@ +import {QualifiedRules} from '@commitlint/types'; +import {Answers, DistinctQuestion, Inquirer} from 'inquirer'; +import wrap from 'word-wrap'; +import Question, {QuestionConfig} from './Question'; +import {PromptConfig, PromptName, Rule, RuleField} from './types'; +import getForcedCaseFn from './utils/case-fn'; +import getFullStopFn from './utils/full-stop-fn'; +import getLeadingBlankFn from './utils/leading-blank-fn'; +import { + enumRuleIsActive, + getEnumList, + getMaxLength, + getMinLength, + ruleIsActive, + ruleIsApplicable, + ruleIsNotApplicable, +} from './utils/rules'; + +export default class Prompter { + rules: QualifiedRules; + prompts: PromptConfig; + + constructor(rules: QualifiedRules, prompts: PromptConfig) { + this.rules = rules; + this.prompts = prompts; + } + + async prompt(inquirer: Inquirer): Promise { + inquirer.registerPrompt( + 'autocomplete', + require('inquirer-autocomplete-prompt') + ); + + const questions = [ + ...this.getHeaderQuestions(), + ...this.getBodyQuestions(), + ...this.getFooterQuestions(), + ]; + const answers = await inquirer.prompt(questions); + + return this.handleAnswers(answers); + } + + getHeaderQuestions(): Array { + // header: type, scope, subject + const headerMaxLength = getMaxLength(this.getRule('header', 'max-length')); + const headerMinLength = getMinLength(this.getRule('header', 'min-length')); + const questions: Array = []; + + const headerRuleFields: RuleField[] = ['type', 'scope', 'subject']; + + headerRuleFields.forEach((name) => { + const questionConfig = this.getRuleQuestionConfig(name); + if (questionConfig) { + const instance = new Question(name, questionConfig); + const combineHeader = this.combineHeader.bind(this); + instance.onBeforeAsk = function (answers) { + const headerRemainLength = + headerMaxLength - combineHeader(answers).length; + this.maxLength = Math.min(this.maxLength, headerRemainLength); + this.minLength = Math.max(this.minLength, headerMinLength); + }; + questions.push(instance.getQuestion()); + } + }); + return questions; + } + + getBodyQuestions(): Array { + // body + const questionConfig = this.getRuleQuestionConfig('body'); + + if (!questionConfig) return []; + else return [new Question('body', questionConfig).getQuestion()]; + } + + getFooterQuestions(): Array { + const footerQuestionConfig = this.getRuleQuestionConfig('footer'); + + if (!footerQuestionConfig) return []; + + const footerMaxLength = footerQuestionConfig.maxLength; + const footerMinLength = footerQuestionConfig.minLength; + + const fields: PromptName[] = [ + 'isBreaking', + 'breakingBody', + 'breaking', + 'isIssueAffected', + 'issuesBody', + 'issues', + 'footer', + ]; + + return fields + .filter((name) => name in this.prompts.questions) + .map((name) => { + const {questions, messages} = this.prompts; + const instance = new Question(name, { + messages: { + title: questions[name]?.description, + ...messages, + }, + maxLength: footerMaxLength, + minLength: footerMinLength, + }); + + const combineFooter = this.combineFooter.bind(this); + instance.onBeforeAsk = function (answers) { + const remainLength = footerMaxLength - combineFooter(answers).length; + this.maxLength = Math.min(this.maxLength, remainLength); + this.minLength = Math.max(this.minLength, footerMinLength); + }; + + if (name === 'isBreaking') { + instance.setQuestionProperty({ + default: false, + }); + } + + if (name === 'breaking') { + instance.setQuestionProperty({ + when: (answers: Answers) => { + return answers.isBreaking; + }, + }); + } + + if (name === 'breakingBody') { + instance.setQuestionProperty({ + when: (answers: Answers) => { + return answers.isBreaking && !answers.body; + }, + }); + } + + if (name === 'isIssueAffected') { + instance.setQuestionProperty({ + default: false, + }); + } + + if (name === 'issues') { + instance.setQuestionProperty({ + when: (answers: Answers) => { + return answers.isIssueAffected; + }, + }); + } + + if (name === 'issuesBody') { + instance.setQuestionProperty({ + when: (answers: Answers) => { + return ( + answers.isIssueAffected && + !answers.body && + !answers.breakingBody + ); + }, + }); + } + + return instance.getQuestion(); + }); + } + + getRuleQuestionConfig(rulePrefix: RuleField): QuestionConfig | null { + const {messages, questions} = this.prompts; + const questionSettings = questions[rulePrefix]; + const emptyRule = this.getRule(rulePrefix, 'empty'); + const mustBeEmpty = + emptyRule && ruleIsActive(emptyRule) && ruleIsApplicable(emptyRule); + + if (mustBeEmpty) { + return null; + } + + const canBeSkip = !( + emptyRule && + ruleIsActive(emptyRule) && + ruleIsNotApplicable(emptyRule) + ); + + const enumRule = this.getRule(rulePrefix, 'enum'); + const enumRuleList = + enumRule && enumRuleIsActive(enumRule) ? getEnumList(enumRule) : null; + let enumList; + + if (enumRuleList) { + const enumDescriptions = questionSettings?.['enum']; + + if (enumDescriptions) { + const enumNames = Object.keys(enumDescriptions); + const longest = Math.max( + ...enumRuleList.map((enumName) => enumName.length) + ); + // TODO emoji + title + enumList = enumRuleList + .sort((a, b) => enumNames.indexOf(a) - enumNames.indexOf(b)) + .map((enumName) => { + const enumDescription = enumDescriptions[enumName]; + if (enumDescription) { + return { + name: + `${enumName}:`.padEnd(longest + 4) + + enumDescription['description'], + value: enumName, + short: enumName, + }; + } else { + return enumName; + } + }); + } else { + enumList = enumRuleList; + } + } + + return { + skip: canBeSkip, + enumList, + caseFn: getForcedCaseFn(this.getRule(rulePrefix, 'case')), + fullStopFn: getFullStopFn(this.getRule(rulePrefix, 'full-stop')), + minLength: getMinLength(this.getRule(rulePrefix, 'min-length')), + maxLength: getMaxLength(this.getRule(rulePrefix, 'max-length')), + messages: { + title: questionSettings?.['description'], + ...messages, + }, + }; + } + + getRule(key: string, property: string): Rule | undefined { + return this.rules[`${key}-${property}`]; + } + + handleAnswers(answers: Answers): string { + const header = this.combineHeader(answers); + const body = this.combineBody(answers); + const footer = this.combineFooter(answers); + + return [header, body, footer].filter(Boolean).join('\n'); + } + + combineHeader(answers: Answers): string { + const {type = '', scope = '', subject = ''} = answers; + const prefix = `${type}${scope ? `(${scope})` : ''}`; + + return (prefix ? prefix + ': ' : '') + subject; + } + + combineBody(answers: Answers): string { + const maxLineLength = getMaxLength(this.getRule('body', 'max-line-length')); + const leadingBlankFn = getLeadingBlankFn( + this.getRule('body', 'leading-blank') + ); + const {body, breakingBody, issuesBody} = answers; + + const commitBody = body ?? breakingBody ?? issuesBody ?? '-'; + + if (commitBody) { + return leadingBlankFn( + wrap(commitBody, { + width: maxLineLength, + trim: true, + }) + ); + } else { + return ''; + } + } + + combineFooter(answers: Answers): string { + // TODO references-empty + // TODO signed-off-by + const maxLineLength = getMaxLength( + this.getRule('footer', 'max-line-length') + ); + const leadingBlankFn = getLeadingBlankFn( + this.getRule('footer', 'leading-blank') + ); + + const {footer, breaking, issues} = answers; + const footerNotes: string[] = []; + + if (breaking) { + const BREAKING_CHANGE = 'BREAKING CHANGE: '; + footerNotes.push( + wrap( + BREAKING_CHANGE + + breaking.replace(new RegExp(`^${BREAKING_CHANGE}`), ''), + { + width: maxLineLength, + trim: true, + } + ) + ); + } + + if (issues) { + footerNotes.push( + wrap(issues, { + width: maxLineLength, + trim: true, + }) + ); + } + + if (footer) { + footerNotes.push( + wrap(footer, { + width: maxLineLength, + trim: true, + }) + ); + } + + return leadingBlankFn(footerNotes.join('\n')); + } +} diff --git a/@commitlint/cz-commitlint/src/Question.ts b/@commitlint/cz-commitlint/src/Question.ts new file mode 100644 index 0000000000..6adcb95eda --- /dev/null +++ b/@commitlint/cz-commitlint/src/Question.ts @@ -0,0 +1,191 @@ +import chalk from 'chalk'; +import inquirer, { + Answers, + ChoiceCollection, + DistinctQuestion, + Question as InquirerQuestion, +} from 'inquirer'; +import {PromptName} from './types'; +import {CaseFn} from './utils/case-fn'; +import {FullStopFn} from './utils/full-stop-fn'; + +// TODO Require 'title' +type Messages = Partial< + Record< + | 'skip' + | 'title' + | 'max' + | 'min' + | 'emptyWarning' + | 'upperLimitWarning' + | 'lowerLimitWarning', + string + > +>; +export type QuestionConfig = { + messages: Messages; + maxLength: number; + minLength: number; + defaultValue?: string | boolean; + skip?: boolean; + enumList?: ChoiceCollection<{ + name: string; + value: string; + }>; + fullStopFn?: FullStopFn; + caseFn?: CaseFn; +}; +export default class Question { + #data: DistinctQuestion; + messages: Messages; + skip: boolean; + caseFn: CaseFn; + fullStopFn: FullStopFn; + maxLength: number; + minLength: number; + // hooks + onBeforeAsk?: (_: Answers) => void; + constructor( + name: PromptName, + { + defaultValue, + enumList, + messages, + skip, + fullStopFn = (_: string) => _, + caseFn = (_: string) => _, + maxLength = Infinity, + minLength = 0, + }: QuestionConfig + ) { + this.messages = messages; + this.skip = skip ?? false; + this.maxLength = maxLength; + this.minLength = minLength; + this.fullStopFn = fullStopFn; + this.caseFn = caseFn; + + if (enumList) { + this.#data = { + type: 'list', + name: name, + message: this.decorateMessage, + choices: skip + ? [ + ...enumList, + new inquirer.Separator(), + { + name: 'empty', + value: '', + }, + ] + : enumList, + default: defaultValue, + }; + } else { + this.#data = { + type: /^is[A-Z]/.test(name) ? 'confirm' : 'input', + name: name, + message: this.decorateMessage, + default: defaultValue, + transformer: this.transformer, + }; + } + + Object.assign(this.#data, { + filter: this.filter, + validate: this.validate, + }); + + this.#data.filter = this.filter; + this.#data.validate = this.validate; + } + + getQuestion(): DistinctQuestion { + return this.#data; + } + + getQuestionType(): string | undefined { + return this.#data.type; + } + + getQuestionName(): string | undefined { + return this.#data.name; + } + + setQuestionProperty(property: InquirerQuestion): void { + Object.assign(this.#data, property); + } + + validate: (input: string) => boolean | string = (input) => { + const filterSubject = this.filter(input); + + const questionName = this.getQuestionName() ?? ''; + + if (!this.skip && filterSubject.length === 0) { + return this.messages['emptyWarning']?.replace('%s', questionName) ?? ''; + } + + if (filterSubject.length > this.maxLength) { + return ( + this.messages['upperLimitWarning'] + ?.replace('%s', questionName) + .replace('%d', `${filterSubject.length - this.maxLength}`) ?? '' + ); + } + + if (filterSubject.length < this.minLength) { + return ( + this.messages['lowerLimitWarning'] + ?.replace('%s', questionName) + .replace('%d', `${this.minLength - filterSubject.length}`) ?? '' + ); + } + + return true; + }; + + filter: (input: string) => string = (input) => { + return this.caseFn(this.fullStopFn(input.trim())); + }; + + transformer: (input: string, answers: Answers) => string = (input) => { + if (this.maxLength === Infinity && this.minLength === 0) { + return input; + } + const filterSubject = this.filter(input); + const color = + filterSubject.length <= this.maxLength && + filterSubject.length >= this.minLength + ? chalk.green + : chalk.red; + return color('(' + filterSubject.length + ') ' + input); + }; + + decorateMessage: (answers: Answers) => string = (answers) => { + this.onBeforeAsk && this.onBeforeAsk(answers); + if (this.getQuestionType() === 'input') { + const countLimitMessage = (() => { + const messages = []; + if (this.minLength > 0 && this.messages['min']) { + messages.push( + this.messages['min'].replace('%d', this.minLength + '') + ); + } + if (this.maxLength < Infinity && this.messages['max']) { + return this.messages['max'].replace('%d', this.maxLength + ''); + } + + return messages.join(''); + })(); + + const skipMessage = this.skip ? this.messages['skip'] ?? '' : ''; + + return ( + this.messages['title'] + skipMessage + ':' + countLimitMessage + '\n' + ); + } else { + return this.messages['title'] + ':'; + } + }; +} diff --git a/@commitlint/cz-commitlint/src/defaultSettings.ts b/@commitlint/cz-commitlint/src/defaultSettings.ts new file mode 100644 index 0000000000..53e48069c2 --- /dev/null +++ b/@commitlint/cz-commitlint/src/defaultSettings.ts @@ -0,0 +1,106 @@ +export const prompt = { + messages: { + skip: '(press enter to skip)', + max: '(max %d chars)', + min: '(min %d chars)', + emptyWarning: '(%s is required)', + upperLimitWarning: '%s is %d characters longer than the upper limit', + lowerLimitWarning: '%s is %d characters less than the lower limit', + }, + 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".)', + }, + }, +}; diff --git a/@commitlint/cz-commitlint/src/index.ts b/@commitlint/cz-commitlint/src/index.ts new file mode 100644 index 0000000000..fab1366b6b --- /dev/null +++ b/@commitlint/cz-commitlint/src/index.ts @@ -0,0 +1,17 @@ +import load from '@commitlint/load'; +import {Inquirer} from 'inquirer'; +import {prompt} from './defaultSettings'; +import Prompter from './Prompter'; + +type Commit = (message: string) => void; +/** + * Entry point for commitizen + * @param inquirer instance passed by commitizen, unused + * @param commit callback to execute with complete commit message + * @return {void} + */ +export function prompter(inquirer: Inquirer, commit: Commit): void { + load().then(({rules}) => { + new Prompter(rules, prompt).prompt(inquirer).then(commit); + }); +} diff --git a/@commitlint/cz-commitlint/src/types.ts b/@commitlint/cz-commitlint/src/types.ts new file mode 100644 index 0000000000..2b614f0689 --- /dev/null +++ b/@commitlint/cz-commitlint/src/types.ts @@ -0,0 +1,43 @@ +import {RuleConfigCondition, RuleConfigSeverity} from '@commitlint/types'; + +export type Rule = + | Readonly<[RuleConfigSeverity.Disabled]> + | Readonly<[RuleConfigSeverity, RuleConfigCondition]> + | Readonly<[RuleConfigSeverity, RuleConfigCondition, unknown]>; + +export type RuleField = + | 'header' + | 'type' + | 'scope' + | 'subject' + | 'body' + | 'footer'; + +export type PromptName = + | RuleField + | 'isBreaking' + | 'breakingBody' + | 'breaking' + | 'isIssueAffected' + | 'issuesBody' + | 'issues'; + +export type PromptConfig = { + messages: {[K: string]: string}; + questions: Partial< + Record< + PromptName, + { + description?: string; + messages?: {[K: string]: string}; + enum?: { + [enumName: string]: { + description?: string; + title?: string; + emoji?: string; + }; + }; + } + > + >; +}; diff --git a/@commitlint/cz-commitlint/src/utils/case-fn.test.ts b/@commitlint/cz-commitlint/src/utils/case-fn.test.ts new file mode 100644 index 0000000000..8a5392e6fe --- /dev/null +++ b/@commitlint/cz-commitlint/src/utils/case-fn.test.ts @@ -0,0 +1,115 @@ +import {RuleConfigSeverity} from '@commitlint/types'; +import getForcedCaseFn from './forced-case-fn'; + +test('should not apply', () => { + let rule = getForcedCaseFn(['name', [RuleConfigSeverity.Disabled]]); + expect(rule('test')).toBe('test'); + expect(rule('test-foo')).toBe('test-foo'); + expect(rule('testFoo')).toBe('testFoo'); + expect(rule('TEST_FOO')).toBe('TEST_FOO'); + + rule = getForcedCaseFn(); + expect(rule('test')).toBe('test'); + expect(rule('test-foo')).toBe('test-foo'); + expect(rule('testFoo')).toBe('testFoo'); + expect(rule('TEST_FOO')).toBe('TEST_FOO'); + + rule = getForcedCaseFn(['name', [RuleConfigSeverity.Warning, 'never']]); + expect(rule('test')).toBe('test'); + expect(rule('test-foo')).toBe('test-foo'); + expect(rule('testFoo')).toBe('testFoo'); + expect(rule('TEST_FOO')).toBe('TEST_FOO'); + + rule = getForcedCaseFn([ + 'name', + [RuleConfigSeverity.Warning, 'always', ['camel-case', 'lowercase']], + ]); + expect(rule('test')).toBe('test'); + expect(rule('test-foo')).toBe('test-foo'); + expect(rule('testFoo')).toBe('testFoo'); + expect(rule('TEST_FOO')).toBe('TEST_FOO'); +}); + +test('should throw error on invalid casing', () => { + expect(() => + getForcedCaseFn(['name', [RuleConfigSeverity.Warning, 'always']]) + ).toThrow('Unknown target case "undefined"'); + + expect(() => + getForcedCaseFn(['name', [RuleConfigSeverity.Warning, 'always', 'foo']]) + ).toThrow('Unknown target case "foo"'); +}); + +test('should convert text correctly', () => { + let rule = getForcedCaseFn([ + 'name', + [RuleConfigSeverity.Warning, 'always', 'camel-case'], + ]); + expect(rule('TEST_FOOBar-baz baz')).toBe('testFooBarBazBaz'); + + rule = getForcedCaseFn([ + 'name', + [RuleConfigSeverity.Warning, 'always', 'kebab-case'], + ]); + expect(rule('TEST_FOOBar-baz baz')).toBe('test-foo-bar-baz-baz'); + + rule = getForcedCaseFn([ + 'name', + [RuleConfigSeverity.Warning, 'always', 'snake-case'], + ]); + expect(rule('TEST_FOOBar-baz baz')).toBe('test_foo_bar_baz_baz'); + + rule = getForcedCaseFn([ + 'name', + [RuleConfigSeverity.Warning, 'always', 'pascal-case'], + ]); + expect(rule('TEST_FOOBar-baz baz')).toBe('TestFooBarBazBaz'); + + rule = getForcedCaseFn([ + 'name', + [RuleConfigSeverity.Warning, 'always', 'start-case'], + ]); + expect(rule('TEST_FOOBar-baz baz')).toBe('TEST FOO Bar Baz Baz'); + + rule = getForcedCaseFn([ + 'name', + [RuleConfigSeverity.Warning, 'always', 'upper-case'], + ]); + expect(rule('TEST_FOOBar-baz baz')).toBe('TEST_FOOBAR-BAZ BAZ'); + + rule = getForcedCaseFn([ + 'name', + [RuleConfigSeverity.Warning, 'always', 'uppercase'], + ]); + expect(rule('TEST_FOOBar-baz baz')).toBe('TEST_FOOBAR-BAZ BAZ'); + + rule = getForcedCaseFn([ + 'name', + [RuleConfigSeverity.Warning, 'always', 'sentence-case'], + ]); + expect(rule('TEST_FOOBar-baz baz')).toBe('Test_foobar-baz baz'); + + rule = getForcedCaseFn([ + 'name', + [RuleConfigSeverity.Warning, 'always', 'sentencecase'], + ]); + expect(rule('TEST_FOOBar-baz baz')).toBe('Test_foobar-baz baz'); + + rule = getForcedCaseFn([ + 'name', + [RuleConfigSeverity.Warning, 'always', 'lower-case'], + ]); + expect(rule('TEST_FOOBar-baz baz')).toBe('test_foobar-baz baz'); + + rule = getForcedCaseFn([ + 'name', + [RuleConfigSeverity.Warning, 'always', 'lowercase'], + ]); + expect(rule('TEST_FOOBar-baz baz')).toBe('test_foobar-baz baz'); + + rule = getForcedCaseFn([ + 'name', + [RuleConfigSeverity.Warning, 'always', 'lowerCase'], + ]); + expect(rule('TEST_FOOBar-baz baz')).toBe('test_foobar-baz baz'); +}); diff --git a/@commitlint/cz-commitlint/src/utils/case-fn.ts b/@commitlint/cz-commitlint/src/utils/case-fn.ts new file mode 100644 index 0000000000..5de423948d --- /dev/null +++ b/@commitlint/cz-commitlint/src/utils/case-fn.ts @@ -0,0 +1,54 @@ +import camelCase from 'lodash/camelCase'; +import kebabCase from 'lodash/kebabCase'; +import snakeCase from 'lodash/snakeCase'; +import startCase from 'lodash/startCase'; +import upperFirst from 'lodash/upperFirst'; +import {Rule} from '../types'; +import {ruleIsActive, ruleIsNotApplicable} from './rules'; + +export type CaseFn = (input: string) => string; + +/** + * Get forced case for rule + * @param rule to parse + * @return transform function applying the enforced case + */ +export default function getForcedCaseFn(rule?: Rule): CaseFn { + const noop = (input: string) => input; + + if (!rule || !ruleIsActive(rule) || ruleIsNotApplicable(rule)) { + return noop; + } + + const target = rule[2]; + + if (Array.isArray(target)) { + return noop; + } + + switch (target) { + case 'camel-case': + return (input: string) => camelCase(input); + case 'kebab-case': + return (input: string) => kebabCase(input); + case 'snake-case': + return (input: string) => snakeCase(input); + case 'pascal-case': + return (input: string) => upperFirst(camelCase(input)); + case 'start-case': + return (input: string) => startCase(input); + case 'upper-case': + case 'uppercase': + return (input: string) => input.toUpperCase(); + case 'sentence-case': + case 'sentencecase': + return (input: string) => + `${input.charAt(0).toUpperCase()}${input.substring(1).toLowerCase()}`; + case 'lower-case': + case 'lowercase': + case 'lowerCase': // Backwards compat config-angular v4 + return (input: string) => input.toLowerCase(); + default: + throw new TypeError(`Unknown target case "${target}"`); + } +} diff --git a/@commitlint/cz-commitlint/src/utils/full-stop-fn.ts b/@commitlint/cz-commitlint/src/utils/full-stop-fn.ts new file mode 100644 index 0000000000..6a593720c8 --- /dev/null +++ b/@commitlint/cz-commitlint/src/utils/full-stop-fn.ts @@ -0,0 +1,34 @@ +import {Rule} from '../types'; +import {ruleIsActive, ruleIsNotApplicable} from './rules'; + +export type FullStopFn = (input: string) => string; + +/** + * Get forced case for rule + * @param rule to parse + * @return transform function applying the enforced case + */ +export default function getFullStopFn(rule?: Rule): FullStopFn { + const noop = (_: string) => _; + + if (!rule || !ruleIsActive(rule)) { + return noop; + } + + if (typeof rule[2] !== 'string') return noop; + + const symbol: string = rule[2]; + + if (ruleIsNotApplicable(rule)) { + return (input: string) => { + while (input.length > 0 && input.endsWith(symbol)) { + input = input.slice(0, input.length - 1); + } + return input; + }; + } else { + return (input: string) => { + return !input.endsWith(symbol) ? input + symbol : input; + }; + } +} diff --git a/@commitlint/cz-commitlint/src/utils/leading-blank-fn.ts b/@commitlint/cz-commitlint/src/utils/leading-blank-fn.ts new file mode 100644 index 0000000000..c65ff8ad2b --- /dev/null +++ b/@commitlint/cz-commitlint/src/utils/leading-blank-fn.ts @@ -0,0 +1,26 @@ +import {Rule} from '../types'; +import {ruleIsActive, ruleIsNotApplicable} from './rules'; + +/** + * Get forced leading for rule + * @param rule to parse + * @return transform function applying the leading + */ +export default function getLeadingBlankFn( + rule?: Rule +): (input: string) => string { + if (!rule || !ruleIsActive(rule)) { + return (input: string): string => input; + } + + const remove = (input: string): string => { + const fragments = input.split('\n'); + return fragments[0] === '' ? fragments.slice(1).join('\n') : input; + }; + const lead = (input: string): string => { + const fragments = input.split('\n'); + return fragments[0] === '' ? input : ['', ...fragments].join('\n'); + }; + + return !ruleIsNotApplicable(rule) ? lead : remove; +} diff --git a/@commitlint/cz-commitlint/src/utils/rules.test.ts b/@commitlint/cz-commitlint/src/utils/rules.test.ts new file mode 100644 index 0000000000..b6c8e5097c --- /dev/null +++ b/@commitlint/cz-commitlint/src/utils/rules.test.ts @@ -0,0 +1,119 @@ +import {RuleConfigSeverity} from '@commitlint/types'; +import { + enumRuleIsActive, + getHasName, + getLength, + getRuleName, + getRulePrefix, + getRules, + ruleIsActive, +} from './rules'; + +test('getRulePrefix', () => { + expect(getRulePrefix('body-leading-blank')).toEqual('body'); + expect(getRulePrefix('body-max-line-length')).toEqual('body'); + expect(getRulePrefix('footer-leading-blank')).toEqual('footer'); + expect(getRulePrefix('footer-max-line-length')).toEqual('footer'); + expect(getRulePrefix('header-max-length')).toEqual('header'); + expect(getRulePrefix('scope-case')).toEqual('scope'); + expect(getRulePrefix('scope-enum')).toEqual('scope'); + expect(getRulePrefix('subject-case')).toEqual('subject'); + expect(getRulePrefix('subject-empty')).toEqual('subject'); + expect(getRulePrefix('subject-full-stop')).toEqual('subject'); + expect(getRulePrefix('type-case')).toEqual('type'); + expect(getRulePrefix('type-empty')).toEqual('type'); + expect(getRulePrefix('type-enum')).toEqual('type'); +}); + +test('getRuleName', () => { + expect(getRuleName('body-leading-blank')).toEqual('leading-blank'); + expect(getRuleName('body-max-line-length')).toEqual('max-line-length'); + expect(getRuleName('footer-leading-blank')).toEqual('leading-blank'); + expect(getRuleName('footer-max-line-length')).toEqual('max-line-length'); + expect(getRuleName('header-max-length')).toEqual('max-length'); + expect(getRuleName('scope-case')).toEqual('case'); + expect(getRuleName('scope-enum')).toEqual('enum'); + expect(getRuleName('subject-case')).toEqual('case'); + expect(getRuleName('subject-empty')).toEqual('empty'); + expect(getRuleName('subject-full-stop')).toEqual('full-stop'); + expect(getRuleName('type-case')).toEqual('case'); + expect(getRuleName('type-empty')).toEqual('empty'); + expect(getRuleName('type-enum')).toEqual('enum'); +}); + +test('ruleIsActive', () => { + expect(ruleIsActive(['', [RuleConfigSeverity.Error, 'always', 100]])).toBe( + true + ); + expect(ruleIsActive(['', [RuleConfigSeverity.Warning, 'never', 100]])).toBe( + true + ); + expect(ruleIsActive(['', [RuleConfigSeverity.Disabled, 'always', 100]])).toBe( + false + ); + expect(ruleIsActive(['', [RuleConfigSeverity.Error]] as any)).toBe(true); +}); + +test('getLength', () => { + expect(getLength(['', [RuleConfigSeverity.Error, 'always', 100]])).toBe(100); + expect(getLength(['', [RuleConfigSeverity.Warning, 'never', 100]])).toBe( + Infinity + ); + expect(getLength(['', [RuleConfigSeverity.Disabled, 'always', 100]])).toBe( + Infinity + ); + expect(getLength(['', [RuleConfigSeverity.Error, 100]] as any)).toBe( + Infinity + ); + + const rules: any = { + 'body-max-line-length': [2, 'always', 100], + 'header-max-length': [2, 'always', 100], + 'test-max-length': [RuleConfigSeverity.Disabled, 'always', 100], + }; + let lengthRule = getRules('header', rules).find(getHasName('max-length')); + expect(getLength(lengthRule)).toBe(100); + + lengthRule = getRules('body', rules).find(getHasName('max-length')); + expect(getLength(lengthRule)).toBe(Infinity); + + lengthRule = getRules('test', rules).find(getHasName('max-length')); + expect(getLength(lengthRule)).toBe(Infinity); +}); + +test('check enum rule filters', () => { + const rules: any = { + 'enum-string': [RuleConfigSeverity.Warning, 'always', ['1', '2', '3']], + 'type-enum': [RuleConfigSeverity.Error, 'always', ['build', 'chore', 'ci']], + 'scope-enum': [RuleConfigSeverity.Error, 'never', ['cli', 'core', 'lint']], + 'bar-enum': [RuleConfigSeverity.Disabled, 'always', ['foo', 'bar', 'baz']], + }; + + let enumRule = getRules('type', rules) + .filter(getHasName('enum')) + .find(enumRuleIsActive); + expect(enumRule).toEqual([ + 'type-enum', + [2, 'always', ['build', 'chore', 'ci']], + ]); + + enumRule = getRules('string', rules) + .filter(getHasName('enum')) + .find(enumRuleIsActive); + expect(enumRule).toEqual(undefined); + + enumRule = getRules('enum', rules) + .filter(getHasName('string')) + .find(enumRuleIsActive); + expect(enumRule).toEqual(['enum-string', [1, 'always', ['1', '2', '3']]]); + + enumRule = getRules('bar', rules) + .filter(getHasName('enum')) + .find(enumRuleIsActive); + expect(enumRule).toEqual(undefined); + + enumRule = getRules('scope', rules) + .filter(getHasName('enum')) + .find(enumRuleIsActive); + expect(enumRule).toEqual(undefined); +}); diff --git a/@commitlint/cz-commitlint/src/utils/rules.ts b/@commitlint/cz-commitlint/src/utils/rules.ts new file mode 100644 index 0000000000..8a3e5e5de8 --- /dev/null +++ b/@commitlint/cz-commitlint/src/utils/rules.ts @@ -0,0 +1,89 @@ +import {RuleConfigSeverity} from '@commitlint/types'; +import {Rule} from '../types'; + +/** + * Check if a rule definition is active + * @param rule to check + * @return if the rule definition is active + */ +export function ruleIsActive( + rule: T +): rule is Exclude> { + if (rule && Array.isArray(rule)) { + return rule[0] > RuleConfigSeverity.Disabled; + } + return false; +} + +/** + * Check if a rule definition is applicable + * @param rule to check + * @return if the rule definition is applicable + */ +export function ruleIsApplicable( + rule: Rule +): rule is + | Readonly<[RuleConfigSeverity, 'always']> + | Readonly<[RuleConfigSeverity, 'always', unknown]> { + if (rule && Array.isArray(rule)) { + return rule[1] === 'always'; + } + return false; +} + +/** + * Check if a rule definition is applicable + * @param rule to check + * @return if the rule definition is applicable + */ +export function ruleIsNotApplicable( + rule: Rule +): rule is + | Readonly<[RuleConfigSeverity, 'never']> + | Readonly<[RuleConfigSeverity, 'never', unknown]> { + if (rule && Array.isArray(rule)) { + return rule[1] === 'never'; + } + return false; +} + +export function enumRuleIsActive( + rule: Rule +): rule is Readonly< + [RuleConfigSeverity.Warning | RuleConfigSeverity.Error, 'always', string[]] +> { + return ( + ruleIsActive(rule) && + ruleIsApplicable(rule) && + Array.isArray(rule[2]) && + rule[2].length > 0 + ); +} + +export function getEnumList(rule: Rule): string[] { + return Array.isArray(rule[2]) ? rule[2] : []; +} + +export function getMaxLength(rule?: Rule): number { + if ( + rule && + ruleIsActive(rule) && + ruleIsApplicable(rule) && + typeof rule[2] === 'number' + ) { + return rule[2]; + } + return Infinity; +} + +export function getMinLength(rule?: Rule): number { + if ( + rule && + ruleIsActive(rule) && + ruleIsApplicable(rule) && + typeof rule[2] === 'number' + ) { + return rule[2]; + } + return 0; +} diff --git a/@commitlint/cz-commitlint/tsconfig.json b/@commitlint/cz-commitlint/tsconfig.json new file mode 100644 index 0000000000..2a6d93a0fa --- /dev/null +++ b/@commitlint/cz-commitlint/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.shared.json", + "compilerOptions": { + "composite": true, + "rootDir": "./src", + "outDir": "./lib" + }, + "include": ["./src"], + "exclude": ["./src/**/*.test.ts", "./lib/**/*"], + "references": [{"path": "../cli"}] +} diff --git a/tsconfig.json b/tsconfig.json index 4dec545ae4..4a7263020b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,7 @@ {"path": "@commitlint/core"}, {"path": "@commitlint/cli"}, {"path": "@commitlint/travis-cli"}, - {"path": "@commitlint/prompt"} + {"path": "@commitlint/prompt"}, + {"path": "@commitlint/cz-commitlint"} ] } diff --git a/yarn.lock b/yarn.lock index 1811736c56..4d68a752fc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2199,6 +2199,14 @@ dependencies: "@types/node" "*" +"@types/inquirer@^7.3.1": + version "7.3.1" + resolved "https://registry.npm.taobao.org/@types/inquirer/download/@types/inquirer-7.3.1.tgz#1f231224e7df11ccfaf4cf9acbcc3b935fea292d" + integrity sha1-HyMSJOffEcz69M+ay8w7k1/qKS0= + dependencies: + "@types/through" "*" + rxjs "^6.4.0" + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.1" resolved "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz#42995b446db9a48a11a07ec083499a860e9138ff" @@ -2291,6 +2299,13 @@ resolved "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.0.tgz#7036640b4e21cc2f259ae826ce843d277dad8cff" integrity sha512-RJJrrySY7A8havqpGObOB4W92QXKJo63/jFLLgpvOtsGUqbQZ9Sbgl35KMm1DjC6j7AvmmU2bIno+3IyEaemaw== +"@types/through@*": + version "0.0.30" + resolved "https://registry.npm.taobao.org/@types/through/download/@types/through-0.0.30.tgz?cache=0&sync_timestamp=1613384648567&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40types%2Fthrough%2Fdownload%2F%40types%2Fthrough-0.0.30.tgz#e0e42ce77e897bd6aead6f6ea62aeb135b8a3895" + integrity sha1-4OQs536Je9aurW9upirrE1uKOJU= + dependencies: + "@types/node" "*" + "@types/tmp@^0.2.0": version "0.2.0" resolved "https://registry.npmjs.org/@types/tmp/-/tmp-0.2.0.tgz#e3f52b4d7397eaa9193592ef3fdd44dc0af4298c" @@ -2512,11 +2527,6 @@ ansi-colors@^4.1.1: resolved "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== -ansi-escapes@^1.0.0, ansi-escapes@^1.1.0: - version "1.4.0" - resolved "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e" - integrity sha1-06ioOzGapneTZisT52HHkRQiMG4= - ansi-escapes@^3.2.0: version "3.2.0" resolved "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.2.0.tgz#8780b98ff9dbf5638152d1f1fe5c1d7b4442976b" @@ -2529,6 +2539,13 @@ ansi-escapes@^4.2.1, ansi-escapes@^4.3.0: dependencies: type-fest "^0.11.0" +ansi-escapes@^4.3.1: + version "4.3.2" + resolved "https://registry.npm.taobao.org/ansi-escapes/download/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" + integrity sha1-ayKR0dt9mLZSHV8e+kLQ86n+tl4= + dependencies: + type-fest "^0.21.3" + ansi-regex@^2.0.0: version "2.1.1" resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" @@ -2794,15 +2811,6 @@ babel-plugin-polyfill-regenerator@^0.1.2: dependencies: "@babel/helper-define-polyfill-provider" "^0.1.5" -babel-polyfill@^6.3.14: - version "6.26.0" - resolved "https://registry.npmjs.org/babel-polyfill/-/babel-polyfill-6.26.0.tgz#379937abc67d7895970adc621f284cd966cf2153" - integrity sha1-N5k3q8Z9eJWXCtxiHyhM2WbPIVM= - dependencies: - babel-runtime "^6.26.0" - core-js "^2.5.0" - regenerator-runtime "^0.10.5" - babel-preset-current-node-syntax@^1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz#b4399239b89b2a011f9ddbe3e4f401fc40cff73b" @@ -2829,14 +2837,6 @@ babel-preset-jest@^26.6.2: babel-plugin-jest-hoist "^26.6.2" babel-preset-current-node-syntax "^1.0.0" -babel-runtime@^6.26.0: - version "6.26.0" - resolved "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe" - integrity sha1-llxwWGaOgrVde/4E/yM3vItWR/4= - dependencies: - core-js "^2.4.0" - regenerator-runtime "^0.11.0" - balanced-match@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" @@ -3121,7 +3121,7 @@ caseless@~0.12.0: resolved "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= -chalk@^1.0.0, chalk@^1.1.0, chalk@^1.1.1: +chalk@^1.1.1: version "1.1.3" resolved "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" integrity sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg= @@ -3222,13 +3222,6 @@ cli-boxes@^2.2.0: resolved "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz#ddd5035d25094fce220e9cab40a45840a440318f" integrity sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw== -cli-cursor@^1.0.1, cli-cursor@^1.0.2: - version "1.0.2" - resolved "https://registry.npmjs.org/cli-cursor/-/cli-cursor-1.0.2.tgz#64da3f7d56a54412e59794bd62dc35295e8f2987" - integrity sha1-ZNo/fValRBLll5S9Ytw1KV6PKYc= - dependencies: - restore-cursor "^1.0.1" - cli-cursor@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz#b35dac376479facc3e94747d41d0d0f5238ffcb5" @@ -3251,11 +3244,6 @@ cli-truncate@^2.1.0: slice-ansi "^3.0.0" string-width "^4.2.0" -cli-width@^1.0.1: - version "1.1.1" - resolved "https://registry.npmjs.org/cli-width/-/cli-width-1.1.1.tgz#a4d293ef67ebb7b88d4a4d42c0ccf00c4d1e366d" - integrity sha1-pNKT72frt7iNSk1CwMzwDE0eNm0= - cli-width@^2.0.0: version "2.2.1" resolved "https://registry.npmjs.org/cli-width/-/cli-width-2.2.1.tgz#b0433d0b4e9c847ef18868a4ef16fd5fc8271c48" @@ -3659,11 +3647,6 @@ core-js-compat@^3.8.1, core-js-compat@^3.9.0: browserslist "^4.16.3" semver "7.0.0" -core-js@^2.4.0, core-js@^2.5.0: - version "2.6.11" - resolved "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c" - integrity sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg== - core-util-is@1.0.2, core-util-is@~1.0.0: version "1.0.2" resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" @@ -4454,11 +4437,6 @@ execa@^5.0.0: signal-exit "^3.0.3" strip-final-newline "^2.0.0" -exit-hook@^1.0.0: - version "1.1.1" - resolved "https://registry.npmjs.org/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8" - integrity sha1-8FyiM7SMBdVP/wd2XfhQfpXAL/g= - exit@^0.1.2: version "0.1.2" resolved "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" @@ -4595,14 +4573,6 @@ figlet@^1.1.1: resolved "https://registry.npmjs.org/figlet/-/figlet-1.5.0.tgz#2db4d00a584e5155a96080632db919213c3e003c" integrity sha512-ZQJM4aifMpz6H19AW1VqvZ7l4pOE9p7i/3LyxgO2kp+PO/VcDYNqIHEMtkccqIhTXMKci4kjueJr/iCQEaT/Ww== -figures@^1.3.5: - version "1.7.0" - resolved "https://registry.npmjs.org/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e" - integrity sha1-y+Hjr/zxzUS4DK3+0o3Hk6lwHS4= - dependencies: - escape-string-regexp "^1.0.5" - object-assign "^4.1.0" - figures@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz#3ab1a2d2a62c8bfb431a0c94cb797a2fce27c962" @@ -5425,11 +5395,6 @@ imurmurhash@^0.1.4: resolved "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= -in-publish@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/in-publish/-/in-publish-2.0.0.tgz#e20ff5e3a2afc2690320b6dc552682a9c7fadf51" - integrity sha1-4g/146KvwmkDILbcVSaCqcf631E= - indent-string@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/indent-string/-/indent-string-2.1.0.tgz#8e2d48348742121b4a8218b7a137e9a52049dc80" @@ -5489,23 +5454,16 @@ init-package-json@^2.0.2: validate-npm-package-license "^3.0.4" validate-npm-package-name "^3.0.0" -inquirer@0.11.0: - version "0.11.0" - resolved "https://registry.npmjs.org/inquirer/-/inquirer-0.11.0.tgz#7448bfa924092af311d47173bbab990cae2bb027" - integrity sha1-dEi/qSQJKvMR1HFzu6uZDK4rsCc= +inquirer-autocomplete-prompt@^1.3.0: + version "1.3.0" + resolved "https://registry.npm.taobao.org/inquirer-autocomplete-prompt/download/inquirer-autocomplete-prompt-1.3.0.tgz#fcbba926be2d3cf338e3dd24380ae7c408113b46" + integrity sha1-/LupJr4tPPM4490kOArnxAgRO0Y= dependencies: - ansi-escapes "^1.1.0" - ansi-regex "^2.0.0" - chalk "^1.0.0" - cli-cursor "^1.0.1" - cli-width "^1.0.1" - figures "^1.3.5" - lodash "^3.3.1" - readline2 "^1.0.1" - run-async "^0.1.0" - rx-lite "^3.1.2" - strip-ansi "^3.0.0" - through "^2.3.6" + ansi-escapes "^4.3.1" + chalk "^4.0.0" + figures "^3.2.0" + run-async "^2.4.0" + rxjs "^6.6.2" inquirer@6.5.0: version "6.5.0" @@ -6768,7 +6726,7 @@ lodash.templatesettings@^4.0.0: dependencies: lodash._reinterpolate "^3.0.0" -lodash@4.17.15, lodash@4.x, lodash@^3.3.1, lodash@^4.17.12, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.5.1: +lodash@4.17.15, lodash@4.x, lodash@^4.17.12, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21: version "4.17.21" resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -6780,14 +6738,6 @@ log-symbols@^4.0.0: dependencies: chalk "^4.0.0" -log-update@^1.0.2: - version "1.0.2" - resolved "https://registry.npmjs.org/log-update/-/log-update-1.0.2.tgz#19929f64c4093d2d2e7075a1dad8af59c296b8d1" - integrity sha1-GZKfZMQJPS0ucHWh2tivWcKWuNE= - dependencies: - ansi-escapes "^1.0.0" - cli-cursor "^1.0.2" - log-update@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz#589ecd352471f2a1c0c570287543a64dfd20e0a1" @@ -7235,11 +7185,6 @@ multimatch@^5.0.0: arrify "^2.0.1" minimatch "^3.0.4" -mute-stream@0.0.5: - version "0.0.5" - resolved "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.5.tgz#8fbfabb0a98a253d3184331f9e8deb7372fac6c0" - integrity sha1-j7+rsKmKJT0xhDMfno3rc3L6xsA= - mute-stream@0.0.7: version "0.0.7" resolved "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab" @@ -7330,11 +7275,6 @@ node-int64@^0.4.0: resolved "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" integrity sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs= -node-localstorage@^0.6.0: - version "0.6.0" - resolved "https://registry.npmjs.org/node-localstorage/-/node-localstorage-0.6.0.tgz#45a0601c6932dfde6644a23361f1be173c75d3af" - integrity sha1-RaBgHGky395mRKIzYfG+Fzx1068= - node-modules-regexp@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/node-modules-regexp/-/node-modules-regexp-1.0.0.tgz#8d9dbe28964a4ac5712e9131642107c71e90ec40" @@ -7617,11 +7557,6 @@ once@^1.3.0, once@^1.3.1, once@^1.4.0: dependencies: wrappy "1" -onetime@^1.0.0: - version "1.1.0" - resolved "https://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz#a1f7838f8314c516f05ecefcbc4ccfe04b4ed789" - integrity sha1-ofeDj4MUxRbwXs78vEzP4EtO14k= - onetime@^2.0.0: version "2.0.1" resolved "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz#067428230fd67443b2794b22bba528b6867962d4" @@ -8436,15 +8371,6 @@ readdirp@~3.5.0: dependencies: picomatch "^2.2.1" -readline2@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/readline2/-/readline2-1.0.1.tgz#41059608ffc154757b715d9989d199ffbf372e35" - integrity sha1-QQWWCP/BVHV7cV2ZidGZ/783LjU= - dependencies: - code-point-at "^1.0.0" - is-fullwidth-code-point "^1.0.0" - mute-stream "0.0.5" - rechoir@^0.6.2: version "0.6.2" resolved "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384" @@ -8488,16 +8414,6 @@ regenerate@^1.4.0: resolved "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz#b9346d8827e8f5a32f7ba29637d398b69014848a" integrity sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A== -regenerator-runtime@^0.10.5: - version "0.10.5" - resolved "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.5.tgz#336c3efc1220adcedda2c9fab67b5a7955a33658" - integrity sha1-M2w+/BIgrc7dosn6tntaeVWjNlg= - -regenerator-runtime@^0.11.0: - version "0.11.1" - resolved "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" - integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg== - regenerator-runtime@^0.13.4: version "0.13.7" resolved "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz#cac2dacc8a1ea675feaabaeb8ae833898ae46f55" @@ -8738,14 +8654,6 @@ responselike@^1.0.2: dependencies: lowercase-keys "^1.0.0" -restore-cursor@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/restore-cursor/-/restore-cursor-1.0.1.tgz#34661f46886327fed2991479152252df92daa541" - integrity sha1-NGYfRohjJ/7SmRR5FSJS35LapUE= - dependencies: - exit-hook "^1.0.0" - onetime "^1.0.0" - restore-cursor@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf" @@ -8803,13 +8711,6 @@ rsvp@^4.8.4: resolved "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz#c8f155311d167f68f21e168df71ec5b083113734" integrity sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA== -run-async@^0.1.0: - version "0.1.0" - resolved "https://registry.npmjs.org/run-async/-/run-async-0.1.0.tgz#c8ad4a5e110661e402a7d21b530e009f25f8e389" - integrity sha1-yK1KXhEGYeQCp9IbUw4AnyX444k= - dependencies: - once "^1.3.0" - run-async@^2.2.0, run-async@^2.4.0: version "2.4.1" resolved "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455" @@ -8822,11 +8723,6 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" -rx-lite@^3.1.2: - version "3.1.2" - resolved "https://registry.npmjs.org/rx-lite/-/rx-lite-3.1.2.tgz#19ce502ca572665f3b647b10939f97fd1615f102" - integrity sha1-Gc5QLKVyZl87ZHsQk5+X/RYV8QI= - rxjs@^6.4.0: version "6.5.4" resolved "https://registry.npmjs.org/rxjs/-/rxjs-6.5.4.tgz#e0777fe0d184cec7872df147f303572d414e211c" @@ -8841,6 +8737,13 @@ rxjs@^6.6.0: dependencies: tslib "^1.9.0" +rxjs@^6.6.2: + version "6.6.7" + resolved "https://registry.npm.taobao.org/rxjs/download/rxjs-6.6.7.tgz#90ac018acabf491bf65044235d5863c4dab804c9" + integrity sha1-kKwBisq/SRv2UEQjXVhjxNq4BMk= + dependencies: + tslib "^1.9.0" + rxjs@^6.6.3: version "6.6.3" resolved "https://registry.npmjs.org/rxjs/-/rxjs-6.6.3.tgz#8ca84635c4daa900c0d3967a6ee7ac60271ee552" @@ -9617,11 +9520,6 @@ throat@^5.0.0: resolved "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz#c5199235803aad18754a667d659b5e72ce16764b" integrity sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA== -throat@^6.0.0: - version "6.0.0" - resolved "https://registry.npmjs.org/throat/-/throat-6.0.0.tgz#e5d793bff24e2d329e25239978ba79b9c797b3a6" - integrity sha512-xFKdqx9QpWfXq471eaKQ/ao7xOFye4CKc8pyNJ9wU+oa6R4EKPTVY6V7JMqPVMZhB8TUbY5TB/mgU4AYA4Y8dA== - through2@^2.0.0, through2@^2.0.2: version "2.0.5" resolved "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd" @@ -9871,6 +9769,11 @@ type-fest@^0.20.2: resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== +type-fest@^0.21.3: + version "0.21.3" + resolved "https://registry.npm.taobao.org/type-fest/download/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" + integrity sha1-0mCiSwGYQ24TP6JqUkptZfo7Ljc= + type-fest@^0.4.1: version "0.4.1" resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.4.1.tgz#8bdf77743385d8a4f13ba95f610f5ccd68c728f8" @@ -10121,22 +10024,6 @@ verror@1.10.0: core-util-is "1.0.2" extsprintf "^1.2.0" -vorpal@^1.12.0: - version "1.12.0" - resolved "https://registry.npmjs.org/vorpal/-/vorpal-1.12.0.tgz#4be7b2a4e48f8fcfc9cf3648c419d311c522159d" - integrity sha1-S+eypOSPj8/JzzZIxBnTEcUiFZ0= - dependencies: - babel-polyfill "^6.3.14" - chalk "^1.1.0" - in-publish "^2.0.0" - inquirer "0.11.0" - lodash "^4.5.1" - log-update "^1.0.2" - minimist "^1.2.0" - node-localstorage "^0.6.0" - strip-ansi "^3.0.0" - wrap-ansi "^2.0.0" - w3c-hr-time@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz#0a89cdf5cc15822df9c360543676963e0cc308cd" @@ -10240,22 +10127,14 @@ widest-line@^3.1.0: word-wrap@^1.0.3, word-wrap@^1.2.3, word-wrap@~1.2.3: version "1.2.3" - resolved "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" - integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== + resolved "https://registry.npm.taobao.org/word-wrap/download/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" + integrity sha1-YQY29rH3A4kb00dxzLF/uTtHB5w= wordwrap@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus= -wrap-ansi@^2.0.0: - version "2.1.0" - resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85" - integrity sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU= - dependencies: - string-width "^1.0.1" - strip-ansi "^3.0.1" - wrap-ansi@^5.1.0: version "5.1.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz#1fd1f67235d5b6d0fee781056001bfb694c03b09" From 1d270830d6d5e397086ccdaa90e2277455e802dc Mon Sep 17 00:00:00 2001 From: Curly Date: Thu, 1 Apr 2021 20:12:38 +0800 Subject: [PATCH 02/12] test(cz-commitlint): Add Util Function Tests --- @commitlint/cz-commitlint/README.md | 88 ++++++++++ @commitlint/cz-commitlint/TODO | 5 + @commitlint/cz-commitlint/package.json | 5 +- @commitlint/cz-commitlint/src/Prompter.ts | 39 +++-- @commitlint/cz-commitlint/src/Question.ts | 48 +++-- .../cz-commitlint/src/utils/case-fn.test.ts | 83 +++------ .../cz-commitlint/src/utils/case-fn.ts | 2 +- .../src/utils/full-stop-fn-test.ts | 67 +++++++ .../src/utils/leading-blank-fn-test.ts | 37 ++++ .../src/utils/leading-blank-fn.ts | 5 +- .../cz-commitlint/src/utils/rules.test.ts | 165 +++++++++--------- 11 files changed, 347 insertions(+), 197 deletions(-) create mode 100644 @commitlint/cz-commitlint/TODO create mode 100644 @commitlint/cz-commitlint/src/utils/full-stop-fn-test.ts create mode 100644 @commitlint/cz-commitlint/src/utils/leading-blank-fn-test.ts diff --git a/@commitlint/cz-commitlint/README.md b/@commitlint/cz-commitlint/README.md index e69de29bb2..3f572ba595 100644 --- a/@commitlint/cz-commitlint/README.md +++ b/@commitlint/cz-commitlint/README.md @@ -0,0 +1,88 @@ +> Commitizen adapter using the commitlint.config.js + +# @commitlint/cz-commitlint + +This is a commitizen adapter, using this adapter, commitizen works based on commitlint.config.js. + +Submit by commitizen, lint by commitlint, just need maintain one configuration file, Consistent and Scalable. + +The interactive process is inspired by [cz-conventional-changelog](https://github.com/commitizen/cz-conventional-changelog). + +## Getting started + +### Using commitizen adapter + +```bash +npm install --save-dev @commitlint/cz-commitlint commitizen +``` + +In package.json + +``` +{ + "scripts": { + "commit": "git-cz" + }, + "config": { + "commitizen": { + "path": "@commitlint/cz-commitlint" + } + } +} +``` + +### Configure commitlint + +```bash +# Install commitlint cli and conventional config +npm install --save-dev @commitlint/config-conventional @commitlint/cli + +# Simple: config with conventional +echo "module.exports = {extends: ['@commitlint/config-conventional']};" > commitlint.config.js + +# commitlint configuration is shareable, +# Install lerna-scopes +npm install --save-dev @commitlint/config-lerna-scopes +# Scalable: config with lerna-scopes in monorepo mode +echo "module.exports = {extends: ['@commitlint/config-conventional', '@commitlint/config-lerna-scopes']};" > commitlint.config.js +``` + +### Set Git Hooks by husky + +```base + +# ------- using npm ---------- +# Install Husky +npm install husky --save-dev +# Active hooks +npx husky install +# Add commitlint hook +npx husky add .husky/commit-msg 'npx --no-install commitlint --edit $1' +# Add commitizen hook +npx husky add .husky/prepare-commit-msg 'exec < /dev/tty && node_modules/.bin/cz --hook || true' + + +# ------- using yarn ---------- +# Install Husky +yarn add husky --dev +# Active hooks +yarn husky install +# Add commitlint hook +yarn husky add .husky/commit-msg 'yarn --no-install commitlint --edit $1' +# Add commitizen hook +yarn husky add .husky/prepare-commit-msg 'exec < /dev/tty && node_modules/.bin/cz --hook || true' + +``` + +### Try it out + +```bash +git add . +npm run commit +# or +yarn run commit +``` + +## Related + +- [Commitlint Shared Configuration](https://github.com/conventional-changelog/commitlint#shared-configuration) - You can find more shared configurations are available to install and use with commitlint diff --git a/@commitlint/cz-commitlint/TODO b/@commitlint/cz-commitlint/TODO new file mode 100644 index 0000000000..599147f20f --- /dev/null +++ b/@commitlint/cz-commitlint/TODO @@ -0,0 +1,5 @@ +[] jest Test +[] insert prompt settings to commitlint.config.js +[] support emoji and title +[] support multi line +[] recognize "signed-off-by" and "references-empty" rules diff --git a/@commitlint/cz-commitlint/package.json b/@commitlint/cz-commitlint/package.json index acf5b91d4f..ab50e4d478 100644 --- a/@commitlint/cz-commitlint/package.json +++ b/@commitlint/cz-commitlint/package.json @@ -1,5 +1,5 @@ { - "name": "cz-conventional-changelog", + "name": "cz-commitlint", "version": "1.0.0", "description": "Commitizen adapter using the commitlint.config.js", "main": "./lib/index.js", @@ -30,9 +30,6 @@ "lodash": "^4.17.21", "word-wrap": "^1.2.3" }, - "devDependencies": { - "@types/inquirer": "^7.3.1" - }, "peerDependencies": { "commitizen": "^4.0.3", "inquirer": "^8.0.0" diff --git a/@commitlint/cz-commitlint/src/Prompter.ts b/@commitlint/cz-commitlint/src/Prompter.ts index 30eaf3b20d..2640bf4118 100644 --- a/@commitlint/cz-commitlint/src/Prompter.ts +++ b/@commitlint/cz-commitlint/src/Prompter.ts @@ -3,7 +3,7 @@ import {Answers, DistinctQuestion, Inquirer} from 'inquirer'; import wrap from 'word-wrap'; import Question, {QuestionConfig} from './Question'; import {PromptConfig, PromptName, Rule, RuleField} from './types'; -import getForcedCaseFn from './utils/case-fn'; +import getCaseFn from './utils/case-fn'; import getFullStopFn from './utils/full-stop-fn'; import getLeadingBlankFn from './utils/leading-blank-fn'; import { @@ -96,30 +96,24 @@ export default class Prompter { .filter((name) => name in this.prompts.questions) .map((name) => { const {questions, messages} = this.prompts; - const instance = new Question(name, { + + const questionConfigs = { messages: { - title: questions[name]?.description, + title: questions[name]?.description ?? '', ...messages, }, maxLength: footerMaxLength, minLength: footerMinLength, - }); - - const combineFooter = this.combineFooter.bind(this); - instance.onBeforeAsk = function (answers) { - const remainLength = footerMaxLength - combineFooter(answers).length; - this.maxLength = Math.min(this.maxLength, remainLength); - this.minLength = Math.max(this.minLength, footerMinLength); }; if (name === 'isBreaking') { - instance.setQuestionProperty({ - default: false, + Object.assign(questionConfigs, { + defaultValue: false, }); } if (name === 'breaking') { - instance.setQuestionProperty({ + Object.assign(questionConfigs, { when: (answers: Answers) => { return answers.isBreaking; }, @@ -127,7 +121,7 @@ export default class Prompter { } if (name === 'breakingBody') { - instance.setQuestionProperty({ + Object.assign(questionConfigs, { when: (answers: Answers) => { return answers.isBreaking && !answers.body; }, @@ -135,13 +129,13 @@ export default class Prompter { } if (name === 'isIssueAffected') { - instance.setQuestionProperty({ + Object.assign(questionConfigs, { default: false, }); } if (name === 'issues') { - instance.setQuestionProperty({ + Object.assign(questionConfigs, { when: (answers: Answers) => { return answers.isIssueAffected; }, @@ -149,7 +143,7 @@ export default class Prompter { } if (name === 'issuesBody') { - instance.setQuestionProperty({ + Object.assign(questionConfigs, { when: (answers: Answers) => { return ( answers.isIssueAffected && @@ -159,6 +153,13 @@ export default class Prompter { }, }); } + const instance = new Question(name, questionConfigs); + const combineFooter = this.combineFooter.bind(this); + instance.onBeforeAsk = function (answers) { + const remainLength = footerMaxLength - combineFooter(answers).length; + this.maxLength = Math.min(this.maxLength, remainLength); + this.minLength = Math.max(this.minLength, footerMinLength); + }; return instance.getQuestion(); }); @@ -219,12 +220,12 @@ export default class Prompter { return { skip: canBeSkip, enumList, - caseFn: getForcedCaseFn(this.getRule(rulePrefix, 'case')), + caseFn: getCaseFn(this.getRule(rulePrefix, 'case')), fullStopFn: getFullStopFn(this.getRule(rulePrefix, 'full-stop')), minLength: getMinLength(this.getRule(rulePrefix, 'min-length')), maxLength: getMaxLength(this.getRule(rulePrefix, 'max-length')), messages: { - title: questionSettings?.['description'], + title: questionSettings?.['description'] ?? '', ...messages, }, }; diff --git a/@commitlint/cz-commitlint/src/Question.ts b/@commitlint/cz-commitlint/src/Question.ts index 6adcb95eda..6a3ee0ebfc 100644 --- a/@commitlint/cz-commitlint/src/Question.ts +++ b/@commitlint/cz-commitlint/src/Question.ts @@ -1,32 +1,32 @@ import chalk from 'chalk'; import inquirer, { Answers, + AsyncDynamicQuestionProperty, ChoiceCollection, DistinctQuestion, - Question as InquirerQuestion, } from 'inquirer'; import {PromptName} from './types'; import {CaseFn} from './utils/case-fn'; import {FullStopFn} from './utils/full-stop-fn'; -// TODO Require 'title' -type Messages = Partial< - Record< - | 'skip' - | 'title' - | 'max' - | 'min' - | 'emptyWarning' - | 'upperLimitWarning' - | 'lowerLimitWarning', - string - > ->; +type Messages = Record<'title', string> & + Partial< + Record< + | 'skip' + | 'max' + | 'min' + | 'emptyWarning' + | 'upperLimitWarning' + | 'lowerLimitWarning', + string + > + >; export type QuestionConfig = { messages: Messages; maxLength: number; minLength: number; - defaultValue?: string | boolean; + defaultValue?: string; + when?: AsyncDynamicQuestionProperty; skip?: boolean; enumList?: ChoiceCollection<{ name: string; @@ -48,10 +48,11 @@ export default class Question { constructor( name: PromptName, { - defaultValue, enumList, messages, - skip, + defaultValue, + when, + skip = false, fullStopFn = (_: string) => _, caseFn = (_: string) => _, maxLength = Infinity, @@ -80,23 +81,18 @@ export default class Question { }, ] : enumList, - default: defaultValue, }; } else { this.#data = { type: /^is[A-Z]/.test(name) ? 'confirm' : 'input', name: name, message: this.decorateMessage, - default: defaultValue, transformer: this.transformer, }; } - Object.assign(this.#data, { - filter: this.filter, - validate: this.validate, - }); - + this.#data.default = defaultValue; + this.#data.when = when; this.#data.filter = this.filter; this.#data.validate = this.validate; } @@ -113,10 +109,6 @@ export default class Question { return this.#data.name; } - setQuestionProperty(property: InquirerQuestion): void { - Object.assign(this.#data, property); - } - validate: (input: string) => boolean | string = (input) => { const filterSubject = this.filter(input); diff --git a/@commitlint/cz-commitlint/src/utils/case-fn.test.ts b/@commitlint/cz-commitlint/src/utils/case-fn.test.ts index 8a5392e6fe..038f70589f 100644 --- a/@commitlint/cz-commitlint/src/utils/case-fn.test.ts +++ b/@commitlint/cz-commitlint/src/utils/case-fn.test.ts @@ -1,28 +1,29 @@ import {RuleConfigSeverity} from '@commitlint/types'; -import getForcedCaseFn from './forced-case-fn'; +import getCaseFn from './case-fn'; test('should not apply', () => { - let rule = getForcedCaseFn(['name', [RuleConfigSeverity.Disabled]]); + let rule = getCaseFn([RuleConfigSeverity.Disabled]); expect(rule('test')).toBe('test'); expect(rule('test-foo')).toBe('test-foo'); expect(rule('testFoo')).toBe('testFoo'); expect(rule('TEST_FOO')).toBe('TEST_FOO'); - rule = getForcedCaseFn(); + rule = getCaseFn(); expect(rule('test')).toBe('test'); expect(rule('test-foo')).toBe('test-foo'); expect(rule('testFoo')).toBe('testFoo'); expect(rule('TEST_FOO')).toBe('TEST_FOO'); - rule = getForcedCaseFn(['name', [RuleConfigSeverity.Warning, 'never']]); + rule = getCaseFn([RuleConfigSeverity.Warning, 'never']); expect(rule('test')).toBe('test'); expect(rule('test-foo')).toBe('test-foo'); expect(rule('testFoo')).toBe('testFoo'); expect(rule('TEST_FOO')).toBe('TEST_FOO'); - rule = getForcedCaseFn([ - 'name', - [RuleConfigSeverity.Warning, 'always', ['camel-case', 'lowercase']], + rule = getCaseFn([ + RuleConfigSeverity.Warning, + 'always', + ['camel-case', 'lowercase'], ]); expect(rule('test')).toBe('test'); expect(rule('test-foo')).toBe('test-foo'); @@ -31,85 +32,49 @@ test('should not apply', () => { }); test('should throw error on invalid casing', () => { - expect(() => - getForcedCaseFn(['name', [RuleConfigSeverity.Warning, 'always']]) - ).toThrow('Unknown target case "undefined"'); + expect(() => getCaseFn([RuleConfigSeverity.Warning, 'always'])).toThrow( + 'Unknown target case "undefined"' + ); expect(() => - getForcedCaseFn(['name', [RuleConfigSeverity.Warning, 'always', 'foo']]) + getCaseFn([RuleConfigSeverity.Warning, 'always', 'foo']) ).toThrow('Unknown target case "foo"'); }); test('should convert text correctly', () => { - let rule = getForcedCaseFn([ - 'name', - [RuleConfigSeverity.Warning, 'always', 'camel-case'], - ]); + let rule = getCaseFn([RuleConfigSeverity.Warning, 'always', 'camel-case']); expect(rule('TEST_FOOBar-baz baz')).toBe('testFooBarBazBaz'); - rule = getForcedCaseFn([ - 'name', - [RuleConfigSeverity.Warning, 'always', 'kebab-case'], - ]); + rule = getCaseFn([RuleConfigSeverity.Warning, 'always', 'kebab-case']); expect(rule('TEST_FOOBar-baz baz')).toBe('test-foo-bar-baz-baz'); - rule = getForcedCaseFn([ - 'name', - [RuleConfigSeverity.Warning, 'always', 'snake-case'], - ]); + rule = getCaseFn([RuleConfigSeverity.Warning, 'always', 'snake-case']); expect(rule('TEST_FOOBar-baz baz')).toBe('test_foo_bar_baz_baz'); - rule = getForcedCaseFn([ - 'name', - [RuleConfigSeverity.Warning, 'always', 'pascal-case'], - ]); + rule = getCaseFn([RuleConfigSeverity.Warning, 'always', 'pascal-case']); expect(rule('TEST_FOOBar-baz baz')).toBe('TestFooBarBazBaz'); - rule = getForcedCaseFn([ - 'name', - [RuleConfigSeverity.Warning, 'always', 'start-case'], - ]); + rule = getCaseFn([RuleConfigSeverity.Warning, 'always', 'start-case']); expect(rule('TEST_FOOBar-baz baz')).toBe('TEST FOO Bar Baz Baz'); - rule = getForcedCaseFn([ - 'name', - [RuleConfigSeverity.Warning, 'always', 'upper-case'], - ]); + rule = getCaseFn([RuleConfigSeverity.Warning, 'always', 'upper-case']); expect(rule('TEST_FOOBar-baz baz')).toBe('TEST_FOOBAR-BAZ BAZ'); - rule = getForcedCaseFn([ - 'name', - [RuleConfigSeverity.Warning, 'always', 'uppercase'], - ]); + rule = getCaseFn([RuleConfigSeverity.Warning, 'always', 'uppercase']); expect(rule('TEST_FOOBar-baz baz')).toBe('TEST_FOOBAR-BAZ BAZ'); - rule = getForcedCaseFn([ - 'name', - [RuleConfigSeverity.Warning, 'always', 'sentence-case'], - ]); + rule = getCaseFn([RuleConfigSeverity.Warning, 'always', 'sentence-case']); expect(rule('TEST_FOOBar-baz baz')).toBe('Test_foobar-baz baz'); - rule = getForcedCaseFn([ - 'name', - [RuleConfigSeverity.Warning, 'always', 'sentencecase'], - ]); + rule = getCaseFn([RuleConfigSeverity.Warning, 'always', 'sentencecase']); expect(rule('TEST_FOOBar-baz baz')).toBe('Test_foobar-baz baz'); - rule = getForcedCaseFn([ - 'name', - [RuleConfigSeverity.Warning, 'always', 'lower-case'], - ]); + rule = getCaseFn([RuleConfigSeverity.Warning, 'always', 'lower-case']); expect(rule('TEST_FOOBar-baz baz')).toBe('test_foobar-baz baz'); - rule = getForcedCaseFn([ - 'name', - [RuleConfigSeverity.Warning, 'always', 'lowercase'], - ]); + rule = getCaseFn([RuleConfigSeverity.Warning, 'always', 'lowercase']); expect(rule('TEST_FOOBar-baz baz')).toBe('test_foobar-baz baz'); - rule = getForcedCaseFn([ - 'name', - [RuleConfigSeverity.Warning, 'always', 'lowerCase'], - ]); + rule = getCaseFn([RuleConfigSeverity.Warning, 'always', 'lowerCase']); expect(rule('TEST_FOOBar-baz baz')).toBe('test_foobar-baz baz'); }); diff --git a/@commitlint/cz-commitlint/src/utils/case-fn.ts b/@commitlint/cz-commitlint/src/utils/case-fn.ts index 5de423948d..0bb098c7bf 100644 --- a/@commitlint/cz-commitlint/src/utils/case-fn.ts +++ b/@commitlint/cz-commitlint/src/utils/case-fn.ts @@ -13,7 +13,7 @@ export type CaseFn = (input: string) => string; * @param rule to parse * @return transform function applying the enforced case */ -export default function getForcedCaseFn(rule?: Rule): CaseFn { +export default function getCaseFn(rule?: Rule): CaseFn { const noop = (input: string) => input; if (!rule || !ruleIsActive(rule) || ruleIsNotApplicable(rule)) { diff --git a/@commitlint/cz-commitlint/src/utils/full-stop-fn-test.ts b/@commitlint/cz-commitlint/src/utils/full-stop-fn-test.ts new file mode 100644 index 0000000000..a1b56a6f7d --- /dev/null +++ b/@commitlint/cz-commitlint/src/utils/full-stop-fn-test.ts @@ -0,0 +1,67 @@ +import {RuleConfigSeverity} from '@commitlint/types'; +import getFullStopFn from './full-stop-fn'; + +test('should not apply', () => { + let rule = getFullStopFn([RuleConfigSeverity.Disabled]); + expect(rule('test.')).toBe('test.'); + expect(rule('test')).toBe('test'); + expect(rule('test..')).toBe('test..'); + expect(rule('')).toBe(''); + + rule = getFullStopFn(); + expect(rule('test.')).toBe('test.'); + expect(rule('test')).toBe('test'); + expect(rule('test..')).toBe('test..'); + expect(rule('')).toBe(''); + + rule = getFullStopFn([RuleConfigSeverity.Disabled, 'always']); + expect(rule('test.')).toBe('test.'); + expect(rule('test')).toBe('test'); + expect(rule('test..')).toBe('test..'); + expect(rule('')).toBe(''); + + rule = getFullStopFn([RuleConfigSeverity.Disabled, 'always', 1]); + expect(rule('test.')).toBe('test.'); + expect(rule('test')).toBe('test'); + expect(rule('test..')).toBe('test..'); + expect(rule('')).toBe(''); + + rule = getFullStopFn([RuleConfigSeverity.Disabled, 'never']); + expect(rule('test.')).toBe('test.'); + expect(rule('test')).toBe('test'); + expect(rule('test..')).toBe('test..'); + expect(rule('')).toBe(''); + + rule = getFullStopFn([RuleConfigSeverity.Disabled, 'never', ['.']]); + expect(rule('test.')).toBe('test.'); + expect(rule('test')).toBe('test'); + expect(rule('test..')).toBe('test..'); + expect(rule('')).toBe(''); +}); + +test('should add full stop', () => { + let rule = getFullStopFn([RuleConfigSeverity.Error, 'always', '.']); + expect(rule('test')).toBe('test.'); + expect(rule('test.')).toBe('test.'); + expect(rule('')).toBe('.'); + + rule = getFullStopFn([RuleConfigSeverity.Error, 'always', '\n']); + expect(rule('test')).toBe('test\n'); + expect(rule('test.')).toBe('test.\n'); + expect(rule('')).toBe('\n'); +}); + +test('should remove full stop', () => { + let rule = getFullStopFn([RuleConfigSeverity.Error, 'never', '.']); + expect(rule('test')).toBe('test'); + expect(rule('test.')).toBe('test'); + expect(rule('')).toBe(''); + expect(rule('test..')).toBe('test'); + expect(rule('test.end')).toBe('test.end'); + + rule = getFullStopFn([RuleConfigSeverity.Error, 'never', '\n']); + expect(rule('test')).toBe('test'); + expect(rule('test.')).toBe('test.'); + expect(rule('test\n\n')).toBe('test'); + expect(rule('test.\n')).toBe('test.'); +}); diff --git a/@commitlint/cz-commitlint/src/utils/leading-blank-fn-test.ts b/@commitlint/cz-commitlint/src/utils/leading-blank-fn-test.ts new file mode 100644 index 0000000000..25d787d88b --- /dev/null +++ b/@commitlint/cz-commitlint/src/utils/leading-blank-fn-test.ts @@ -0,0 +1,37 @@ +import {RuleConfigSeverity} from '@commitlint/types'; +import getLeadingBlankFn from './leading-blank-fn'; + +test('should not apply', () => { + let rule = getLeadingBlankFn([RuleConfigSeverity.Disabled]); + expect(rule('test')).toBe('test'); + expect(rule('\ntest')).toBe('\ntest'); + expect(rule('aaa\ntest')).toBe('aaa\ntest'); + expect(rule('')).toBe(''); + + rule = getLeadingBlankFn(); + expect(rule('test')).toBe('test'); + expect(rule('\ntest')).toBe('\ntest'); + expect(rule('aaa\ntest')).toBe('aaa\ntest'); + expect(rule('')).toBe(''); +}); + +test('should add leading blank', () => { + const rule = getLeadingBlankFn([RuleConfigSeverity.Error, 'always']); + expect(rule('test')).toBe('\ntest'); + expect(rule('\ntest')).toBe('\ntest'); + expect(rule('\n\ntest')).toBe('\n\ntest'); + expect(rule('aaa\ntest')).toBe('\naaa\ntest'); + expect(rule('\naaa\ntest')).toBe('\naaa\ntest'); + expect(rule('')).toBe('\n'); +}); + +test('should remove leading blank', () => { + const rule = getLeadingBlankFn([RuleConfigSeverity.Error, 'never']); + expect(rule('test')).toBe('test'); + expect(rule('\ntest')).toBe('test'); + expect(rule('\n\ntest')).toBe('test'); + expect(rule('aaa\ntest')).toBe('aaa\ntest'); + expect(rule('\naaa\ntest')).toBe('aaa\ntest'); + expect(rule('\n\n\naaa\ntest')).toBe('aaa\ntest'); + expect(rule('')).toBe(''); +}); diff --git a/@commitlint/cz-commitlint/src/utils/leading-blank-fn.ts b/@commitlint/cz-commitlint/src/utils/leading-blank-fn.ts index c65ff8ad2b..e12c571276 100644 --- a/@commitlint/cz-commitlint/src/utils/leading-blank-fn.ts +++ b/@commitlint/cz-commitlint/src/utils/leading-blank-fn.ts @@ -15,7 +15,10 @@ export default function getLeadingBlankFn( const remove = (input: string): string => { const fragments = input.split('\n'); - return fragments[0] === '' ? fragments.slice(1).join('\n') : input; + while (fragments.length > 0 && fragments[0] === '') { + fragments.shift(); + } + return fragments.join('\n'); }; const lead = (input: string): string => { const fragments = input.split('\n'); diff --git a/@commitlint/cz-commitlint/src/utils/rules.test.ts b/@commitlint/cz-commitlint/src/utils/rules.test.ts index b6c8e5097c..a0510aec81 100644 --- a/@commitlint/cz-commitlint/src/utils/rules.test.ts +++ b/@commitlint/cz-commitlint/src/utils/rules.test.ts @@ -1,87 +1,92 @@ import {RuleConfigSeverity} from '@commitlint/types'; import { enumRuleIsActive, - getHasName, - getLength, - getRuleName, - getRulePrefix, - getRules, + getEnumList, + getMaxLength, + getMinLength, ruleIsActive, + ruleIsApplicable, + ruleIsNotApplicable, } from './rules'; -test('getRulePrefix', () => { - expect(getRulePrefix('body-leading-blank')).toEqual('body'); - expect(getRulePrefix('body-max-line-length')).toEqual('body'); - expect(getRulePrefix('footer-leading-blank')).toEqual('footer'); - expect(getRulePrefix('footer-max-line-length')).toEqual('footer'); - expect(getRulePrefix('header-max-length')).toEqual('header'); - expect(getRulePrefix('scope-case')).toEqual('scope'); - expect(getRulePrefix('scope-enum')).toEqual('scope'); - expect(getRulePrefix('subject-case')).toEqual('subject'); - expect(getRulePrefix('subject-empty')).toEqual('subject'); - expect(getRulePrefix('subject-full-stop')).toEqual('subject'); - expect(getRulePrefix('type-case')).toEqual('type'); - expect(getRulePrefix('type-empty')).toEqual('type'); - expect(getRulePrefix('type-enum')).toEqual('type'); +test('ruleIsActive', () => { + expect(ruleIsActive([RuleConfigSeverity.Error, 'always'])).toBe(true); + expect(ruleIsActive([RuleConfigSeverity.Warning, 'never'])).toBe(true); + expect(ruleIsActive([RuleConfigSeverity.Disabled, 'always'])).toBe(false); + expect(ruleIsActive([RuleConfigSeverity.Error] as any)).toBe(true); }); -test('getRuleName', () => { - expect(getRuleName('body-leading-blank')).toEqual('leading-blank'); - expect(getRuleName('body-max-line-length')).toEqual('max-line-length'); - expect(getRuleName('footer-leading-blank')).toEqual('leading-blank'); - expect(getRuleName('footer-max-line-length')).toEqual('max-line-length'); - expect(getRuleName('header-max-length')).toEqual('max-length'); - expect(getRuleName('scope-case')).toEqual('case'); - expect(getRuleName('scope-enum')).toEqual('enum'); - expect(getRuleName('subject-case')).toEqual('case'); - expect(getRuleName('subject-empty')).toEqual('empty'); - expect(getRuleName('subject-full-stop')).toEqual('full-stop'); - expect(getRuleName('type-case')).toEqual('case'); - expect(getRuleName('type-empty')).toEqual('empty'); - expect(getRuleName('type-enum')).toEqual('enum'); +test('ruleIsApplicable', () => { + expect(ruleIsApplicable([RuleConfigSeverity.Error, 'always'])).toBe(true); + expect(ruleIsApplicable([RuleConfigSeverity.Warning, 'always'])).toBe(true); + expect(ruleIsApplicable([RuleConfigSeverity.Disabled, 'always'])).toBe(true); + expect(ruleIsApplicable(undefined as any)).toBe(false); + expect(ruleIsApplicable('' as any)).toBe(false); + expect(ruleIsApplicable([RuleConfigSeverity.Disabled])).toBe(false); + expect(ruleIsApplicable([RuleConfigSeverity.Disabled, 'never'])).toBe(false); }); -test('ruleIsActive', () => { - expect(ruleIsActive(['', [RuleConfigSeverity.Error, 'always', 100]])).toBe( - true - ); - expect(ruleIsActive(['', [RuleConfigSeverity.Warning, 'never', 100]])).toBe( +test('ruleIsNotApplicable', () => { + expect(ruleIsNotApplicable([RuleConfigSeverity.Error, 'never'])).toBe(true); + expect(ruleIsNotApplicable([RuleConfigSeverity.Warning, 'never'])).toBe(true); + expect(ruleIsNotApplicable([RuleConfigSeverity.Disabled, 'never'])).toBe( true ); - expect(ruleIsActive(['', [RuleConfigSeverity.Disabled, 'always', 100]])).toBe( + expect(ruleIsNotApplicable(undefined as any)).toBe(false); + expect(ruleIsNotApplicable('' as any)).toBe(false); + expect(ruleIsNotApplicable([RuleConfigSeverity.Error] as any)).toBe(false); + expect(ruleIsNotApplicable([RuleConfigSeverity.Error, 'always'])).toBe(false); + expect(ruleIsNotApplicable([RuleConfigSeverity.Error, 'always', 100])).toBe( false ); - expect(ruleIsActive(['', [RuleConfigSeverity.Error]] as any)).toBe(true); }); -test('getLength', () => { - expect(getLength(['', [RuleConfigSeverity.Error, 'always', 100]])).toBe(100); - expect(getLength(['', [RuleConfigSeverity.Warning, 'never', 100]])).toBe( - Infinity - ); - expect(getLength(['', [RuleConfigSeverity.Disabled, 'always', 100]])).toBe( - Infinity - ); - expect(getLength(['', [RuleConfigSeverity.Error, 100]] as any)).toBe( - Infinity - ); +test('getMaxLength', () => { + expect(getMaxLength([RuleConfigSeverity.Error, 'always', 100])).toBe(100); + expect(getMaxLength([RuleConfigSeverity.Warning, 'never'])).toBe(Infinity); + expect(getMaxLength([RuleConfigSeverity.Disabled, 'always'])).toBe(Infinity); + expect(getMaxLength([RuleConfigSeverity.Error] as any)).toBe(Infinity); const rules: any = { 'body-max-line-length': [2, 'always', 100], 'header-max-length': [2, 'always', 100], 'test-max-length': [RuleConfigSeverity.Disabled, 'always', 100], }; - let lengthRule = getRules('header', rules).find(getHasName('max-length')); - expect(getLength(lengthRule)).toBe(100); + let lengthRule = rules['header-max-length']; + expect(getMaxLength(lengthRule)).toBe(100); + + lengthRule = rules['body-max-line-length']; + expect(getMaxLength(lengthRule)).toBe(100); - lengthRule = getRules('body', rules).find(getHasName('max-length')); - expect(getLength(lengthRule)).toBe(Infinity); + lengthRule = rules['body-max-length']; + expect(getMaxLength(lengthRule)).toBe(Infinity); - lengthRule = getRules('test', rules).find(getHasName('max-length')); - expect(getLength(lengthRule)).toBe(Infinity); + lengthRule = rules['test-max-length']; + expect(getMaxLength(lengthRule)).toBe(Infinity); }); -test('check enum rule filters', () => { +test('getMinLength', () => { + expect(getMinLength([RuleConfigSeverity.Error, 'always', 10])).toBe(10); + expect(getMinLength([RuleConfigSeverity.Warning, 'never'])).toBe(0); + expect(getMinLength([RuleConfigSeverity.Disabled, 'always'])).toBe(0); + expect(getMinLength([RuleConfigSeverity.Error] as any)).toBe(0); + + const rules: any = { + 'body-min-length': [2, 'always', 10], + 'footer-min-length': [2, 'always', 20], + 'test-min-length': [RuleConfigSeverity.Disabled, 'always', 100], + }; + let lengthRule = rules['header-min-length']; + expect(getMinLength(lengthRule)).toBe(0); + + lengthRule = rules['body-min-length']; + expect(getMinLength(lengthRule)).toBe(10); + + lengthRule = rules['test-min-length']; + expect(getMinLength(lengthRule)).toBe(0); +}); + +test('enumRuleIsActive', () => { const rules: any = { 'enum-string': [RuleConfigSeverity.Warning, 'always', ['1', '2', '3']], 'type-enum': [RuleConfigSeverity.Error, 'always', ['build', 'chore', 'ci']], @@ -89,31 +94,21 @@ test('check enum rule filters', () => { 'bar-enum': [RuleConfigSeverity.Disabled, 'always', ['foo', 'bar', 'baz']], }; - let enumRule = getRules('type', rules) - .filter(getHasName('enum')) - .find(enumRuleIsActive); - expect(enumRule).toEqual([ - 'type-enum', - [2, 'always', ['build', 'chore', 'ci']], - ]); - - enumRule = getRules('string', rules) - .filter(getHasName('enum')) - .find(enumRuleIsActive); - expect(enumRule).toEqual(undefined); - - enumRule = getRules('enum', rules) - .filter(getHasName('string')) - .find(enumRuleIsActive); - expect(enumRule).toEqual(['enum-string', [1, 'always', ['1', '2', '3']]]); - - enumRule = getRules('bar', rules) - .filter(getHasName('enum')) - .find(enumRuleIsActive); - expect(enumRule).toEqual(undefined); - - enumRule = getRules('scope', rules) - .filter(getHasName('enum')) - .find(enumRuleIsActive); - expect(enumRule).toEqual(undefined); + expect(enumRuleIsActive(rules['type-enum'])).toBe(true); + expect(enumRuleIsActive(rules['string-enum'])).toBe(false); + expect(enumRuleIsActive(rules['enum-string'])).toBe(true); + expect(enumRuleIsActive(rules['bar-enum'])).toBe(false); + expect(enumRuleIsActive(rules['scope-enum'])).toBe(false); +}); + +test('getEnumList', () => { + const rules: any = { + 'type-enum': [RuleConfigSeverity.Error, 'always', ['build', 'chore', 'ci']], + 'scope-enum': [RuleConfigSeverity.Error, 'never', ''], + 'bar-enum': [RuleConfigSeverity.Disabled, 'always'], + }; + + expect(getEnumList(rules['type-enum'])).toEqual(['build', 'chore', 'ci']); + expect(getEnumList(rules['scope-enum'])).toEqual([]); + expect(getEnumList(rules['bar-enum'])).toEqual([]); }); From 56a4d8b7f30f41cee0c38247fb2dd29a8f21e743 Mon Sep 17 00:00:00 2001 From: Curly Date: Tue, 13 Apr 2021 01:13:36 +0800 Subject: [PATCH 03/12] feat(cz-commitlint): add prompt field to commitlint config file, add tests for cz-commitlint prompt field is working for prompt config, settled in commitlint config file, can be defined by shared configurations. --- .gitignore | 1 + @commitlint/config-conventional/index.js | 98 +++++ @commitlint/cz-commitlint/src/Process.ts | 36 ++ @commitlint/cz-commitlint/src/Prompter.ts | 321 ----------------- @commitlint/cz-commitlint/src/Question.ts | 211 +++++------ @commitlint/cz-commitlint/src/SectionBody.ts | 37 ++ .../cz-commitlint/src/SectionFooter.ts | 167 +++++++++ .../cz-commitlint/src/SectionHeader.ts | 62 ++++ .../src/__tests__/Process.test.ts | 240 +++++++++++++ .../src/__tests__/Question.test.ts | 315 ++++++++++++++++ .../src/__tests__/SectionBody.test.ts | 77 ++++ .../src/__tests__/SectionFooter.test.ts | 314 ++++++++++++++++ .../src/__tests__/SectionHeader.test.ts | 114 ++++++ .../cz-commitlint/src/defaultSettings.ts | 106 ------ @commitlint/cz-commitlint/src/index.ts | 7 +- .../services/getRuleQuestionConfig.test.ts | 340 ++++++++++++++++++ .../src/services/getRuleQuestionConfig.ts | 74 ++++ .../src/store/defaultPromptConfigs.ts | 65 ++++ .../cz-commitlint/src/store/prompts.test.ts | 109 ++++++ .../cz-commitlint/src/store/prompts.ts | 39 ++ .../cz-commitlint/src/store/rules.test.ts | 67 ++++ @commitlint/cz-commitlint/src/store/rules.ts | 24 ++ @commitlint/cz-commitlint/src/types.ts | 37 -- .../cz-commitlint/src/utils/rules.test.ts | 11 + @commitlint/cz-commitlint/src/utils/rules.ts | 9 + @commitlint/load/src/load.ts | 28 +- @commitlint/load/src/utils/pick-config.ts | 3 +- @commitlint/types/src/index.ts | 3 +- @commitlint/types/src/load.ts | 7 +- @commitlint/types/src/prompt.ts | 54 +++ yarn.lock | 185 +++++++++- 31 files changed, 2555 insertions(+), 606 deletions(-) create mode 100644 @commitlint/cz-commitlint/src/Process.ts delete mode 100644 @commitlint/cz-commitlint/src/Prompter.ts create mode 100644 @commitlint/cz-commitlint/src/SectionBody.ts create mode 100644 @commitlint/cz-commitlint/src/SectionFooter.ts create mode 100644 @commitlint/cz-commitlint/src/SectionHeader.ts create mode 100644 @commitlint/cz-commitlint/src/__tests__/Process.test.ts create mode 100644 @commitlint/cz-commitlint/src/__tests__/Question.test.ts create mode 100644 @commitlint/cz-commitlint/src/__tests__/SectionBody.test.ts create mode 100644 @commitlint/cz-commitlint/src/__tests__/SectionFooter.test.ts create mode 100644 @commitlint/cz-commitlint/src/__tests__/SectionHeader.test.ts delete mode 100644 @commitlint/cz-commitlint/src/defaultSettings.ts create mode 100644 @commitlint/cz-commitlint/src/services/getRuleQuestionConfig.test.ts create mode 100644 @commitlint/cz-commitlint/src/services/getRuleQuestionConfig.ts create mode 100644 @commitlint/cz-commitlint/src/store/defaultPromptConfigs.ts create mode 100644 @commitlint/cz-commitlint/src/store/prompts.test.ts create mode 100644 @commitlint/cz-commitlint/src/store/prompts.ts create mode 100644 @commitlint/cz-commitlint/src/store/rules.test.ts create mode 100644 @commitlint/cz-commitlint/src/store/rules.ts create mode 100644 @commitlint/types/src/prompt.ts diff --git a/.gitignore b/.gitignore index 8e1e11b2d8..c9d515cd7d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ lib/ package.json.lerna_backup /*.iml tsconfig.tsbuildinfo +coverage diff --git a/@commitlint/config-conventional/index.js b/@commitlint/config-conventional/index.js index 8a7e3bd15a..c3c4c13b34 100644 --- a/@commitlint/config-conventional/index.js +++ b/@commitlint/config-conventional/index.js @@ -33,4 +33,102 @@ module.exports = { ], ], }, + 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".)', + }, + }, + } }; diff --git a/@commitlint/cz-commitlint/src/Process.ts b/@commitlint/cz-commitlint/src/Process.ts new file mode 100644 index 0000000000..f1ec42d36f --- /dev/null +++ b/@commitlint/cz-commitlint/src/Process.ts @@ -0,0 +1,36 @@ +import {QualifiedRules, UserPromptConfig} from '@commitlint/types'; +import {Inquirer} from 'inquirer'; +import { + combineCommitMessage as combineBody, + getQuestions as getBodyQuestions, +} from './SectionBody'; +import { + combineCommitMessage as combineFooter, + getQuestions as getFooterQuestions, +} from './SectionFooter'; +import { + combineCommitMessage as combineHeader, + getQuestions as getHeaderQuestions, +} from './SectionHeader'; +import {setPromptConfig} from './store/prompts'; +import {setRules} from './store/rules'; + +export default async function ( + rules: QualifiedRules, + prompts: UserPromptConfig, + inquirer: Inquirer +): Promise { + setRules(rules); + setPromptConfig(prompts); + const questions = [ + ...getHeaderQuestions(), + ...getBodyQuestions(), + ...getFooterQuestions(), + ]; + const answers = await inquirer.prompt(questions); + const header = combineHeader(answers); + const body = combineBody(answers); + const footer = combineFooter(answers); + + return [header, body, footer].filter(Boolean).join('\n'); +} diff --git a/@commitlint/cz-commitlint/src/Prompter.ts b/@commitlint/cz-commitlint/src/Prompter.ts deleted file mode 100644 index 2640bf4118..0000000000 --- a/@commitlint/cz-commitlint/src/Prompter.ts +++ /dev/null @@ -1,321 +0,0 @@ -import {QualifiedRules} from '@commitlint/types'; -import {Answers, DistinctQuestion, Inquirer} from 'inquirer'; -import wrap from 'word-wrap'; -import Question, {QuestionConfig} from './Question'; -import {PromptConfig, PromptName, Rule, RuleField} from './types'; -import getCaseFn from './utils/case-fn'; -import getFullStopFn from './utils/full-stop-fn'; -import getLeadingBlankFn from './utils/leading-blank-fn'; -import { - enumRuleIsActive, - getEnumList, - getMaxLength, - getMinLength, - ruleIsActive, - ruleIsApplicable, - ruleIsNotApplicable, -} from './utils/rules'; - -export default class Prompter { - rules: QualifiedRules; - prompts: PromptConfig; - - constructor(rules: QualifiedRules, prompts: PromptConfig) { - this.rules = rules; - this.prompts = prompts; - } - - async prompt(inquirer: Inquirer): Promise { - inquirer.registerPrompt( - 'autocomplete', - require('inquirer-autocomplete-prompt') - ); - - const questions = [ - ...this.getHeaderQuestions(), - ...this.getBodyQuestions(), - ...this.getFooterQuestions(), - ]; - const answers = await inquirer.prompt(questions); - - return this.handleAnswers(answers); - } - - getHeaderQuestions(): Array { - // header: type, scope, subject - const headerMaxLength = getMaxLength(this.getRule('header', 'max-length')); - const headerMinLength = getMinLength(this.getRule('header', 'min-length')); - const questions: Array = []; - - const headerRuleFields: RuleField[] = ['type', 'scope', 'subject']; - - headerRuleFields.forEach((name) => { - const questionConfig = this.getRuleQuestionConfig(name); - if (questionConfig) { - const instance = new Question(name, questionConfig); - const combineHeader = this.combineHeader.bind(this); - instance.onBeforeAsk = function (answers) { - const headerRemainLength = - headerMaxLength - combineHeader(answers).length; - this.maxLength = Math.min(this.maxLength, headerRemainLength); - this.minLength = Math.max(this.minLength, headerMinLength); - }; - questions.push(instance.getQuestion()); - } - }); - return questions; - } - - getBodyQuestions(): Array { - // body - const questionConfig = this.getRuleQuestionConfig('body'); - - if (!questionConfig) return []; - else return [new Question('body', questionConfig).getQuestion()]; - } - - getFooterQuestions(): Array { - const footerQuestionConfig = this.getRuleQuestionConfig('footer'); - - if (!footerQuestionConfig) return []; - - const footerMaxLength = footerQuestionConfig.maxLength; - const footerMinLength = footerQuestionConfig.minLength; - - const fields: PromptName[] = [ - 'isBreaking', - 'breakingBody', - 'breaking', - 'isIssueAffected', - 'issuesBody', - 'issues', - 'footer', - ]; - - return fields - .filter((name) => name in this.prompts.questions) - .map((name) => { - const {questions, messages} = this.prompts; - - const questionConfigs = { - messages: { - title: questions[name]?.description ?? '', - ...messages, - }, - maxLength: footerMaxLength, - minLength: footerMinLength, - }; - - if (name === 'isBreaking') { - Object.assign(questionConfigs, { - defaultValue: false, - }); - } - - if (name === 'breaking') { - Object.assign(questionConfigs, { - when: (answers: Answers) => { - return answers.isBreaking; - }, - }); - } - - if (name === 'breakingBody') { - Object.assign(questionConfigs, { - when: (answers: Answers) => { - return answers.isBreaking && !answers.body; - }, - }); - } - - if (name === 'isIssueAffected') { - Object.assign(questionConfigs, { - default: false, - }); - } - - if (name === 'issues') { - Object.assign(questionConfigs, { - when: (answers: Answers) => { - return answers.isIssueAffected; - }, - }); - } - - if (name === 'issuesBody') { - Object.assign(questionConfigs, { - when: (answers: Answers) => { - return ( - answers.isIssueAffected && - !answers.body && - !answers.breakingBody - ); - }, - }); - } - const instance = new Question(name, questionConfigs); - const combineFooter = this.combineFooter.bind(this); - instance.onBeforeAsk = function (answers) { - const remainLength = footerMaxLength - combineFooter(answers).length; - this.maxLength = Math.min(this.maxLength, remainLength); - this.minLength = Math.max(this.minLength, footerMinLength); - }; - - return instance.getQuestion(); - }); - } - - getRuleQuestionConfig(rulePrefix: RuleField): QuestionConfig | null { - const {messages, questions} = this.prompts; - const questionSettings = questions[rulePrefix]; - const emptyRule = this.getRule(rulePrefix, 'empty'); - const mustBeEmpty = - emptyRule && ruleIsActive(emptyRule) && ruleIsApplicable(emptyRule); - - if (mustBeEmpty) { - return null; - } - - const canBeSkip = !( - emptyRule && - ruleIsActive(emptyRule) && - ruleIsNotApplicable(emptyRule) - ); - - const enumRule = this.getRule(rulePrefix, 'enum'); - const enumRuleList = - enumRule && enumRuleIsActive(enumRule) ? getEnumList(enumRule) : null; - let enumList; - - if (enumRuleList) { - const enumDescriptions = questionSettings?.['enum']; - - if (enumDescriptions) { - const enumNames = Object.keys(enumDescriptions); - const longest = Math.max( - ...enumRuleList.map((enumName) => enumName.length) - ); - // TODO emoji + title - enumList = enumRuleList - .sort((a, b) => enumNames.indexOf(a) - enumNames.indexOf(b)) - .map((enumName) => { - const enumDescription = enumDescriptions[enumName]; - if (enumDescription) { - return { - name: - `${enumName}:`.padEnd(longest + 4) + - enumDescription['description'], - value: enumName, - short: enumName, - }; - } else { - return enumName; - } - }); - } else { - enumList = enumRuleList; - } - } - - return { - skip: canBeSkip, - enumList, - caseFn: getCaseFn(this.getRule(rulePrefix, 'case')), - fullStopFn: getFullStopFn(this.getRule(rulePrefix, 'full-stop')), - minLength: getMinLength(this.getRule(rulePrefix, 'min-length')), - maxLength: getMaxLength(this.getRule(rulePrefix, 'max-length')), - messages: { - title: questionSettings?.['description'] ?? '', - ...messages, - }, - }; - } - - getRule(key: string, property: string): Rule | undefined { - return this.rules[`${key}-${property}`]; - } - - handleAnswers(answers: Answers): string { - const header = this.combineHeader(answers); - const body = this.combineBody(answers); - const footer = this.combineFooter(answers); - - return [header, body, footer].filter(Boolean).join('\n'); - } - - combineHeader(answers: Answers): string { - const {type = '', scope = '', subject = ''} = answers; - const prefix = `${type}${scope ? `(${scope})` : ''}`; - - return (prefix ? prefix + ': ' : '') + subject; - } - - combineBody(answers: Answers): string { - const maxLineLength = getMaxLength(this.getRule('body', 'max-line-length')); - const leadingBlankFn = getLeadingBlankFn( - this.getRule('body', 'leading-blank') - ); - const {body, breakingBody, issuesBody} = answers; - - const commitBody = body ?? breakingBody ?? issuesBody ?? '-'; - - if (commitBody) { - return leadingBlankFn( - wrap(commitBody, { - width: maxLineLength, - trim: true, - }) - ); - } else { - return ''; - } - } - - combineFooter(answers: Answers): string { - // TODO references-empty - // TODO signed-off-by - const maxLineLength = getMaxLength( - this.getRule('footer', 'max-line-length') - ); - const leadingBlankFn = getLeadingBlankFn( - this.getRule('footer', 'leading-blank') - ); - - const {footer, breaking, issues} = answers; - const footerNotes: string[] = []; - - if (breaking) { - const BREAKING_CHANGE = 'BREAKING CHANGE: '; - footerNotes.push( - wrap( - BREAKING_CHANGE + - breaking.replace(new RegExp(`^${BREAKING_CHANGE}`), ''), - { - width: maxLineLength, - trim: true, - } - ) - ); - } - - if (issues) { - footerNotes.push( - wrap(issues, { - width: maxLineLength, - trim: true, - }) - ); - } - - if (footer) { - footerNotes.push( - wrap(footer, { - width: maxLineLength, - trim: true, - }) - ); - } - - return leadingBlankFn(footerNotes.join('\n')); - } -} diff --git a/@commitlint/cz-commitlint/src/Question.ts b/@commitlint/cz-commitlint/src/Question.ts index 6a3ee0ebfc..355596cb66 100644 --- a/@commitlint/cz-commitlint/src/Question.ts +++ b/@commitlint/cz-commitlint/src/Question.ts @@ -1,76 +1,63 @@ +import {PromptMessages, PromptName} from '@commitlint/types'; import chalk from 'chalk'; -import inquirer, { - Answers, - AsyncDynamicQuestionProperty, - ChoiceCollection, - DistinctQuestion, -} from 'inquirer'; -import {PromptName} from './types'; +import inquirer, {Answers, ChoiceCollection, DistinctQuestion} from 'inquirer'; import {CaseFn} from './utils/case-fn'; import {FullStopFn} from './utils/full-stop-fn'; -type Messages = Record<'title', string> & - Partial< - Record< - | 'skip' - | 'max' - | 'min' - | 'emptyWarning' - | 'upperLimitWarning' - | 'lowerLimitWarning', - string - > - >; export type QuestionConfig = { - messages: Messages; - maxLength: number; - minLength: number; + title: string; + messages: PromptMessages; + maxLength?: number; + minLength?: number; defaultValue?: string; - when?: AsyncDynamicQuestionProperty; + when?: DistinctQuestion['when']; skip?: boolean; enumList?: ChoiceCollection<{ name: string; value: string; - }>; + }> | null; fullStopFn?: FullStopFn; caseFn?: CaseFn; }; + export default class Question { - #data: DistinctQuestion; - messages: Messages; - skip: boolean; - caseFn: CaseFn; - fullStopFn: FullStopFn; - maxLength: number; - minLength: number; - // hooks - onBeforeAsk?: (_: Answers) => void; + private _question: Readonly; + private messages: PromptMessages; + private skip: boolean; + private _maxLength: number; + private _minLength: number; + private title: string; + private caseFn: CaseFn; + private fullStopFn: FullStopFn; constructor( name: PromptName, { + title, enumList, messages, defaultValue, when, - skip = false, - fullStopFn = (_: string) => _, - caseFn = (_: string) => _, - maxLength = Infinity, - minLength = 0, + skip, + fullStopFn, + caseFn, + maxLength, + minLength, }: QuestionConfig ) { + if (!name || typeof name !== 'string') + throw new Error('Question: name is required'); + + this._maxLength = maxLength ?? Infinity; + this._minLength = minLength ?? 0; this.messages = messages; + this.title = title ?? ''; this.skip = skip ?? false; - this.maxLength = maxLength; - this.minLength = minLength; - this.fullStopFn = fullStopFn; - this.caseFn = caseFn; + this.fullStopFn = fullStopFn ?? ((_: string) => _); + this.caseFn = caseFn ?? ((_: string) => _); - if (enumList) { - this.#data = { + if (enumList && Array.isArray(enumList)) { + this._question = { type: 'list', - name: name, - message: this.decorateMessage, choices: skip ? [ ...enumList, @@ -80,104 +67,120 @@ export default class Question { value: '', }, ] - : enumList, + : [...enumList], + }; + } else if (/^is[A-Z]/.test(name)) { + this._question = { + type: 'confirm', }; } else { - this.#data = { - type: /^is[A-Z]/.test(name) ? 'confirm' : 'input', - name: name, - message: this.decorateMessage, - transformer: this.transformer, + this._question = { + type: 'input', + transformer: this.transformer.bind(this), }; } - this.#data.default = defaultValue; - this.#data.when = when; - this.#data.filter = this.filter; - this.#data.validate = this.validate; + Object.assign(this._question, { + name, + default: defaultValue, + when, + validate: this.validate.bind(this), + filter: this.filter.bind(this), + message: this.decorateMessage.bind(this), + }); } - getQuestion(): DistinctQuestion { - return this.#data; + getMessage(key: string): string { + return this.messages[key] ?? ''; } - getQuestionType(): string | undefined { - return this.#data.type; + get question(): Readonly { + return this._question; } - getQuestionName(): string | undefined { - return this.#data.name; + get maxLength(): number { + return this._maxLength; } - validate: (input: string) => boolean | string = (input) => { - const filterSubject = this.filter(input); + set maxLength(maxLength: number) { + this._maxLength = maxLength; + } - const questionName = this.getQuestionName() ?? ''; + get minLength(): number { + return this._minLength; + } + + set minLength(minLength: number) { + this._minLength = minLength; + } - if (!this.skip && filterSubject.length === 0) { - return this.messages['emptyWarning']?.replace('%s', questionName) ?? ''; + protected beforeQuestionStart(_answers: Answers): void { + return; + } + + protected validate(input: string): boolean | string { + const output = this.filter(input); + const questionName = this.question.name ?? ''; + if (!this.skip && output.length === 0) { + return this.getMessage('emptyWarning').replace(/%s/g, questionName); } - if (filterSubject.length > this.maxLength) { - return ( - this.messages['upperLimitWarning'] - ?.replace('%s', questionName) - .replace('%d', `${filterSubject.length - this.maxLength}`) ?? '' - ); + if (output.length > this.maxLength) { + return this.getMessage('upperLimitWarning') + .replace(/%s/g, questionName) + .replace(/%d/g, `${output.length - this.maxLength}`); } - if (filterSubject.length < this.minLength) { - return ( - this.messages['lowerLimitWarning'] - ?.replace('%s', questionName) - .replace('%d', `${this.minLength - filterSubject.length}`) ?? '' - ); + if (output.length < this.minLength) { + return this.getMessage('lowerLimitWarning') + .replace(/%s/g, questionName) + .replace(/%d/g, `${this.minLength - output.length}`); } return true; - }; + } + + protected filter(input: string): string { + return this.caseFn(this.fullStopFn(input)); + } - filter: (input: string) => string = (input) => { - return this.caseFn(this.fullStopFn(input.trim())); - }; + protected transformer(input: string, _answers: Answers): string { + const output = this.filter(input); - transformer: (input: string, answers: Answers) => string = (input) => { if (this.maxLength === Infinity && this.minLength === 0) { - return input; + return output; } - const filterSubject = this.filter(input); const color = - filterSubject.length <= this.maxLength && - filterSubject.length >= this.minLength + output.length <= this.maxLength && output.length >= this.minLength ? chalk.green : chalk.red; - return color('(' + filterSubject.length + ') ' + input); - }; + return color('(' + output.length + ') ' + input); + } - decorateMessage: (answers: Answers) => string = (answers) => { - this.onBeforeAsk && this.onBeforeAsk(answers); - if (this.getQuestionType() === 'input') { + protected decorateMessage(_answers: Answers): string { + this.beforeQuestionStart && this.beforeQuestionStart(_answers); + if (this.question.type === 'input') { const countLimitMessage = (() => { const messages = []; - if (this.minLength > 0 && this.messages['min']) { + if (this.minLength > 0 && this.getMessage('min')) { messages.push( - this.messages['min'].replace('%d', this.minLength + '') + this.getMessage('min').replace(/%d/g, this.minLength + '') ); } - if (this.maxLength < Infinity && this.messages['max']) { - return this.messages['max'].replace('%d', this.maxLength + ''); + if (this.maxLength < Infinity && this.getMessage('max')) { + messages.push( + this.getMessage('max').replace(/%d/g, this.maxLength + '') + ); } - return messages.join(''); + return messages.join(', '); })(); - const skipMessage = this.skip ? this.messages['skip'] ?? '' : ''; + const skipMessage = this.skip ? this.getMessage('skip') : ''; - return ( - this.messages['title'] + skipMessage + ':' + countLimitMessage + '\n' - ); + return this.title + skipMessage + ': ' + countLimitMessage; } else { - return this.messages['title'] + ':'; + return this.title + ': '; } - }; + } } diff --git a/@commitlint/cz-commitlint/src/SectionBody.ts b/@commitlint/cz-commitlint/src/SectionBody.ts new file mode 100644 index 0000000000..de528b8858 --- /dev/null +++ b/@commitlint/cz-commitlint/src/SectionBody.ts @@ -0,0 +1,37 @@ +import {Answers, DistinctQuestion} from 'inquirer'; +import wrap from 'word-wrap'; +import Question from './Question'; +import getRuleQuestionConfig from './services/getRuleQuestionConfig'; +import {getRule} from './store/rules'; +import getLeadingBlankFn from './utils/leading-blank-fn'; +import {getMaxLength} from './utils/rules'; + +export function getQuestions(): Array { + // body + const questionConfig = getRuleQuestionConfig('body'); + + if (!questionConfig) return []; + else return [new Question('body', questionConfig).question]; +} + +export function combineCommitMessage(answers: Answers): string { + const maxLineLength = getMaxLength(getRule('body', 'max-line-length')); + const leadingBlankFn = getLeadingBlankFn(getRule('body', 'leading-blank')); + const {body, breakingBody, issuesBody} = answers; + + const commitBody = body ?? breakingBody ?? issuesBody ?? '-'; + + if (commitBody) { + return leadingBlankFn( + maxLineLength < Infinity + ? wrap(commitBody, { + width: maxLineLength, + trim: true, + indent: '', + }) + : commitBody.trim() + ); + } else { + return ''; + } +} diff --git a/@commitlint/cz-commitlint/src/SectionFooter.ts b/@commitlint/cz-commitlint/src/SectionFooter.ts new file mode 100644 index 0000000000..fca73b6fda --- /dev/null +++ b/@commitlint/cz-commitlint/src/SectionFooter.ts @@ -0,0 +1,167 @@ +import {PromptName} from '@commitlint/types'; +import {Answers, DistinctQuestion} from 'inquirer'; +import wrap from 'word-wrap'; +import Question, {QuestionConfig} from './Question'; +import getRuleQuestionConfig from './services/getRuleQuestionConfig'; +import {getPromptMessages, getPromptQuestions} from './store/prompts'; +import {getRule} from './store/rules'; +import getLeadingBlankFn from './utils/leading-blank-fn'; +import {getMaxLength} from './utils/rules'; + +export class FooterQuestion extends Question { + footerMaxLength: number; + footerMinLength: number; + constructor( + name: PromptName, + questionConfig: QuestionConfig, + footerMaxLength?: number, + footerMinLength?: number + ) { + super(name, questionConfig); + this.footerMaxLength = footerMaxLength ?? Infinity; + this.footerMinLength = footerMinLength ?? 0; + } + beforeQuestionStart(answers: Answers): void { + const footerRemainLength = + this.footerMaxLength - combineCommitMessage(answers).length - '\n'.length; + this.maxLength = Math.min(this.maxLength, footerRemainLength); + this.minLength = Math.min(this.minLength, this.footerMinLength); + } +} + +export function getQuestions(): Array { + const footerQuestionConfig = getRuleQuestionConfig('footer'); + + if (!footerQuestionConfig) return []; + + const footerMaxLength = footerQuestionConfig.maxLength; + const footerMinLength = footerQuestionConfig.minLength; + + const fields: PromptName[] = [ + 'isBreaking', + 'breakingBody', + 'breaking', + 'isIssueAffected', + 'issuesBody', + 'issues', + 'footer', + ]; + + return fields + .filter((name) => name in getPromptQuestions()) + .map((name) => { + const questions = getPromptQuestions(); + + const questionConfigs = { + title: questions[name]?.description ?? '', + messages: getPromptMessages(), + footerMaxLength, + footerMinLength, + }; + + if (name === 'isBreaking') { + Object.assign(questionConfigs, { + defaultValue: false, + }); + } + + if (name === 'breakingBody') { + Object.assign(questionConfigs, { + when: (answers: Answers) => { + return answers.isBreaking && !answers.body; + }, + }); + } + + if (name === 'breaking') { + Object.assign(questionConfigs, { + when: (answers: Answers) => { + return answers.isBreaking; + }, + }); + } + + if (name === 'isIssueAffected') { + Object.assign(questionConfigs, { + defaultValue: false, + }); + } + + if (name === 'issuesBody') { + Object.assign(questionConfigs, { + when: (answers: Answers) => { + return ( + answers.isIssueAffected && !answers.body && !answers.breakingBody + ); + }, + }); + } + + if (name === 'issues') { + Object.assign(questionConfigs, { + when: (answers: Answers) => { + return answers.isIssueAffected; + }, + }); + } + const instance = new FooterQuestion( + name, + questionConfigs, + footerMaxLength, + footerMinLength + ); + + return instance.question; + }); +} + +export function combineCommitMessage(answers: Answers): string { + // TODO references-empty + // TODO signed-off-by + const maxLineLength = getMaxLength(getRule('footer', 'max-line-length')); + const leadingBlankFn = getLeadingBlankFn(getRule('footer', 'leading-blank')); + + const {footer, breaking, issues} = answers; + const footerNotes: string[] = []; + + if (breaking) { + const BREAKING_CHANGE = 'BREAKING CHANGE: '; + const message = + BREAKING_CHANGE + breaking.replace(new RegExp(`^${BREAKING_CHANGE}`), ''); + footerNotes.push( + maxLineLength < Infinity + ? wrap(message, { + width: maxLineLength, + trim: true, + indent: '', + }) + : message.trim() + ); + } + + if (issues) { + footerNotes.push( + maxLineLength < Infinity + ? wrap(issues, { + width: maxLineLength, + trim: true, + indent: '', + }) + : issues.trim() + ); + } + + if (footer) { + footerNotes.push( + maxLineLength < Infinity + ? wrap(footer, { + width: maxLineLength, + trim: true, + indent: '', + }) + : footer + ); + } + + return leadingBlankFn(footerNotes.join('\n')); +} diff --git a/@commitlint/cz-commitlint/src/SectionHeader.ts b/@commitlint/cz-commitlint/src/SectionHeader.ts new file mode 100644 index 0000000000..9905ddcc33 --- /dev/null +++ b/@commitlint/cz-commitlint/src/SectionHeader.ts @@ -0,0 +1,62 @@ +import {PromptName, RuleField} from '@commitlint/types'; +import {Answers, DistinctQuestion} from 'inquirer'; +import Question, {QuestionConfig} from './Question'; +import getRuleQuestionConfig from './services/getRuleQuestionConfig'; + +export class HeaderQuestion extends Question { + headerMaxLength: number; + headerMinLength: number; + constructor( + name: PromptName, + questionConfig: QuestionConfig, + headerMaxLength?: number, + headerMinLength?: number + ) { + super(name, questionConfig); + this.headerMaxLength = headerMaxLength ?? Infinity; + this.headerMinLength = headerMinLength ?? 0; + } + beforeQuestionStart(answers: Answers): void { + const headerRemainLength = + this.headerMaxLength - combineCommitMessage(answers).length; + this.maxLength = Math.min(this.maxLength, headerRemainLength); + this.minLength = Math.min(this.minLength, this.headerMinLength); + } +} + +export function combineCommitMessage(answers: Answers): string { + const {type = '', scope = '', subject = ''} = answers; + const prefix = `${type}${scope ? `(${scope})` : ''}`; + + if (subject) { + return ((prefix ? prefix + ': ' : '') + subject).trim(); + } else { + return prefix.trim(); + } +} + +export function getQuestions(): Array { + // header: type, scope, subject + const questions: Array = []; + + const headerRuleFields: RuleField[] = ['type', 'scope', 'subject']; + const headerRuleQuestionConfig = getRuleQuestionConfig('header'); + + if (!headerRuleQuestionConfig) { + return []; + } + + headerRuleFields.forEach((name) => { + const questionConfig = getRuleQuestionConfig(name); + if (questionConfig) { + const instance = new HeaderQuestion( + name, + questionConfig, + headerRuleQuestionConfig.maxLength, + headerRuleQuestionConfig.minLength + ); + questions.push(instance.question); + } + }); + return questions; +} diff --git a/@commitlint/cz-commitlint/src/__tests__/Process.test.ts b/@commitlint/cz-commitlint/src/__tests__/Process.test.ts new file mode 100644 index 0000000000..9f4e7e9b0b --- /dev/null +++ b/@commitlint/cz-commitlint/src/__tests__/Process.test.ts @@ -0,0 +1,240 @@ +import {QualifiedRules, UserPromptConfig} from '@commitlint/types'; +import {Answers, DistinctQuestion} from 'inquirer'; +import process from '../Process'; + +const mockShowTitle = jest.fn(); +const mockShowValidation = jest.fn((message) => message); + +// mock inquirer +const mockPrompt = jest.fn(function (questions, answers) { + for (const {name, message, when, filter, validate} of questions) { + if (!when || when(answers)) { + const title = + message && {}.toString.call(message) === '[object Function]' + ? message(answers) + : typeof message === 'string' + ? message + : ''; + mockShowTitle(title); + + const validation: boolean | string = + !validate || validate(answers[name] ?? '', answers); + + if (typeof validation === 'string') { + mockShowValidation(validation); + break; + } else { + if (filter && answers[name]) { + answers[name] = filter(answers[name]); + } + } + } + } +}); + +function InquirerFactory(answers: Answers) { + const inquirer = { + prompt: function (questions: DistinctQuestion) { + return { + then: function (callback: (answers: Answers) => void) { + mockPrompt(questions, answers); + callback(answers); + }, + }; + }, + }; + + return inquirer; +} + +const MESSAGES = { + skip: '(press enter to skip)', + max: 'upper %d chars', + min: '%d chars at least', + emptyWarning: '%s can not be empty', + upperLimitWarning: '%s: %s over limit %d', + lowerLimitWarning: '%s: %s below limit %d', +}; + +let rules: QualifiedRules; +let prompts: UserPromptConfig; + +afterEach(() => { + mockShowTitle.mockClear(); + mockShowValidation.mockClear(); +}); + +describe('conventional-changlog', () => { + beforeEach(() => { + 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', 100], + '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', + ], + ], + } as any; + prompts = { + messages: MESSAGES, + 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".)', + }, + }, + }; + }); + test('should process works well', () => { + const answers = { + type: 'refactor', + scope: 'prompt', + subject: 'refactor prompt based on inquirer', + body: 'inspired by commitizen/cz-conventional-changelog', + isBreaking: true, + breaking: 'refactor types', + isIssueAffected: true, + issues: 'https://github.com/conventional-changelog/commitlint/issues/94', + }; + return process(rules as any, prompts, InquirerFactory(answers) as any).then( + (commitMessage) => { + expect(commitMessage).toBe( + 'refactor(prompt): refactor prompt based on inquirer\n\ninspired by commitizen/cz-conventional-changelog\n\nBREAKING CHANGE: refactor types\nhttps://github.com/conventional-changelog/commitlint/issues/94' + ); + } + ); + }); + + test('should show validation and stop process when subject is empty', () => { + const answers = { + type: 'refactor', + scope: 'prompt', + body: 'inspired by commitizen/cz-conventional-changelog', + isBreaking: true, + breaking: 'refactor types', + isIssueAffected: true, + issues: 'https://github.com/conventional-changelog/commitlint/issues/94', + }; + return process(rules as any, prompts, InquirerFactory(answers) as any).then( + () => { + expect(mockShowValidation).toBeCalledWith('subject can not be empty'); + expect(mockShowTitle).toBeCalledTimes(3); + } + ); + }); +}); diff --git a/@commitlint/cz-commitlint/src/__tests__/Question.test.ts b/@commitlint/cz-commitlint/src/__tests__/Question.test.ts new file mode 100644 index 0000000000..ef197cbf33 --- /dev/null +++ b/@commitlint/cz-commitlint/src/__tests__/Question.test.ts @@ -0,0 +1,315 @@ +import chalk from 'chalk'; +import inquirer, {Answers, InputQuestionOptions} from 'inquirer'; +import Question from '../Question'; + +const MESSAGES = { + skip: '(press enter to skip)', + max: 'upper %d chars', + min: '%d chars at least', + emptyWarning: '%s can not be empty', + upperLimitWarning: '%s: %s over limit %d', + lowerLimitWarning: '%s: %s below limit %d', +}; +const QUESTION_CONFIG = { + title: 'please input', + messages: MESSAGES, +}; + +describe('name', () => { + test('should throw error when name is not a meaningful string', () => { + expect( + () => + new Question('' as any, { + ...QUESTION_CONFIG, + }) + ).toThrow(); + + expect( + () => + new Question( + function () { + return 'scope'; + } as any, + { + ...QUESTION_CONFIG, + } + ) + ).toThrow(); + }); + + test('should set name when name is valid', () => { + expect( + new Question('test' as any, { + ...QUESTION_CONFIG, + }).question + ).toHaveProperty('name', 'test'); + }); +}); + +describe('type', () => { + test('should return "list" type when enumList is array', () => { + const question = new Question('scope', { + ...QUESTION_CONFIG, + enumList: ['cli', 'core'], + }).question; + expect(question).toHaveProperty('type', 'list'); + expect(question).toHaveProperty('choices', ['cli', 'core']); + expect(question).not.toHaveProperty('transformer'); + }); + + test('should contain "skip" list item when enumList is array and skip is true', () => { + const question = new Question('scope', { + ...QUESTION_CONFIG, + enumList: ['cli', 'core'], + skip: true, + }).question; + expect(question).toHaveProperty('type', 'list'); + expect(question).toHaveProperty('choices', [ + 'cli', + 'core', + new inquirer.Separator(), + { + name: 'empty', + value: '', + }, + ]); + expect(question).not.toHaveProperty('transformer'); + }); + + test('should return "confirm" type when name is start with "is"', () => { + const question = new Question('isSubmit' as any, { + ...QUESTION_CONFIG, + }).question; + expect(question).toHaveProperty('type', 'confirm'); + expect(question).not.toHaveProperty('choices'); + expect(question).not.toHaveProperty('transformer'); + }); + + test('should return "input" type in other cases', () => { + const question = new Question('body', { + ...QUESTION_CONFIG, + }).question; + expect(question).toHaveProperty('type', 'input'); + expect(question).not.toHaveProperty('choices'); + expect(question).toHaveProperty('transformer', expect.any(Function)); + }); +}); + +describe('message', () => { + test('should display title when it is not input', () => { + const question = new Question('body', { + ...QUESTION_CONFIG, + enumList: ['cli', 'core'], + }).question; + expect(question).toHaveProperty('message', expect.any(Function)); + expect((question.message as any)()).toBe('please input: '); + }); + + test('should display skip hint when it is input and can skip', () => { + const question = new Question('body' as any, { + ...QUESTION_CONFIG, + skip: true, + }).question; + expect(question).toHaveProperty('message', expect.any(Function)); + expect((question.message as any)()).toBe( + 'please input(press enter to skip): ' + ); + }); + + test('should not display skip hint when it is input and without skip string', () => { + const question = new Question('scope', { + ...QUESTION_CONFIG, + messages: {}, + skip: true, + } as any).question; + expect(question).toHaveProperty('message', expect.any(Function)); + expect((question.message as any)()).toBe('please input: '); + }); + + test('should display upper limit hint when it is input and has max length', () => { + const question = new Question('scope', { + ...QUESTION_CONFIG, + maxLength: 80, + } as any).question; + expect(question).toHaveProperty('message', expect.any(Function)); + expect((question.message as any)()).toBe('please input: upper 80 chars'); + }); + + test('should display lower limit hint when it is input and has min length', () => { + const question = new Question('scope', { + ...QUESTION_CONFIG, + minLength: 10, + } as any).question; + expect(question).toHaveProperty('message', expect.any(Function)); + expect((question.message as any)()).toBe('please input: 10 chars at least'); + }); + + test('should display hints with correct format', () => { + const question = new Question('scope', { + ...QUESTION_CONFIG, + minLength: 10, + maxLength: 80, + skip: true, + } as any).question; + expect(question).toHaveProperty('message', expect.any(Function)); + expect((question.message as any)()).toBe( + 'please input(press enter to skip): 10 chars at least, upper 80 chars' + ); + }); + + test('should execute function beforeQuestionStart when init message', () => { + const mockFn = jest.fn(); + class CustomQuestion extends Question { + beforeQuestionStart(answers: Answers): void { + mockFn(answers); + } + } + const question = new CustomQuestion('body', { + ...QUESTION_CONFIG, + } as any).question; + expect(question).toHaveProperty('message', expect.any(Function)); + + const answers = { + header: 'This is header', + footer: 'This is footer', + }; + (question.message as any)(answers); + expect(mockFn).toHaveBeenCalledWith(answers); + }); +}); + +describe('filter', () => { + test('should auto fix case and full-stop', () => { + const question = new Question('body', { + ...QUESTION_CONFIG, + caseFn: (input: string) => input[0].toUpperCase() + input.slice(1), + fullStopFn: (input: string) => input + '!', + }).question; + + expect(question.filter?.('xxxx', {})).toBe('Xxxx!'); + }); + + test('should works well when does not pass caseFn/fullStopFn', () => { + const question = new Question('body', { + ...QUESTION_CONFIG, + }).question; + + expect(question.filter?.('xxxx', {})).toBe('xxxx'); + }); +}); + +describe('validate', () => { + test('should display empty warning when can not skip but string is empty', () => { + const question = new Question('body', { + ...QUESTION_CONFIG, + skip: false, + }).question; + + expect(question.validate?.('')).toBe('body can not be empty'); + }); + + test('should ignore empty validation when can skip', () => { + const question = new Question('body', { + ...QUESTION_CONFIG, + skip: true, + }).question; + + expect(question.validate?.('')).toBe(true); + }); + + test('should display upper limit warning when char count is over upper limit', () => { + const question = new Question('body', { + ...QUESTION_CONFIG, + maxLength: 5, + }).question; + + expect(question.validate?.('xxxxxx')).toBe('body: body over limit 1'); + }); + + test('should display lower limit warning when char count is less than lower limit', () => { + const question = new Question('body', { + ...QUESTION_CONFIG, + minLength: 5, + }).question; + + expect(question.validate?.('xxx')).toBe('body: body below limit 2'); + }); + + test('should validate the final submit string', () => { + const question = new Question('body', { + ...QUESTION_CONFIG, + caseFn: () => '', + skip: false, + }).question; + + expect(question.validate?.('xxxx')).not.toBe(true); + }); +}); + +describe('transformer', () => { + test('should auto transform case and full-stop', () => { + const question = new Question('body', { + ...QUESTION_CONFIG, + caseFn: (input: string) => input[0].toUpperCase() + input.slice(1), + fullStopFn: (input: string) => input + '!', + }).question; + + expect( + (question as InputQuestionOptions)?.transformer?.('xxxx', {}, {}) + ).toBe('Xxxx!'); + }); + + test('should char count with green color when in the limit range', () => { + let question = new Question('body', { + ...QUESTION_CONFIG, + maxLength: 5, + }).question; + + expect( + (question as InputQuestionOptions)?.transformer?.('xxx', {}, {}) + ).toEqual(chalk.green(`(3) xxx`)); + + question = new Question('body', { + ...QUESTION_CONFIG, + minLength: 2, + }).question; + + expect( + (question as InputQuestionOptions)?.transformer?.('xxx', {}, {}) + ).toEqual(chalk.green(`(3) xxx`)); + }); + + test('should char count with red color when over the limit range', () => { + let question = new Question('body', { + ...QUESTION_CONFIG, + maxLength: 5, + }).question; + + expect( + (question as InputQuestionOptions)?.transformer?.('xxxxxx', {}, {}) + ).toEqual(chalk.red(`(6) xxxxxx`)); + + question = new Question('body', { + ...QUESTION_CONFIG, + minLength: 2, + }).question; + + expect( + (question as InputQuestionOptions)?.transformer?.('x', {}, {}) + ).toEqual(chalk.red(`(1) x`)); + }); +}); + +describe('inquirer question', () => { + test('should pass "when" and "default" field to inquirer question', () => { + const when = (answers: Answers) => !!answers.header; + const question = new Question('body', { + ...QUESTION_CONFIG, + when, + defaultValue: 'update', + }).question; + + expect(question).toHaveProperty('default', 'update'); + expect(question).toHaveProperty('when', when); + }); +}); diff --git a/@commitlint/cz-commitlint/src/__tests__/SectionBody.test.ts b/@commitlint/cz-commitlint/src/__tests__/SectionBody.test.ts new file mode 100644 index 0000000000..a8b3fa909b --- /dev/null +++ b/@commitlint/cz-commitlint/src/__tests__/SectionBody.test.ts @@ -0,0 +1,77 @@ +import {RuleConfigSeverity} from '@commitlint/types'; +import {combineCommitMessage, getQuestions} from '../SectionBody'; +import {setRules} from '../store/rules'; + +describe('getQuestions', () => { + test('should exclude question when body must be empty', () => { + setRules({ + 'body-empty': [RuleConfigSeverity.Error, 'always'], + }); + const questions = getQuestions(); + expect(questions).toHaveLength(0); + }); + + test('should only return body question', () => { + setRules({}); + const questions = getQuestions(); + expect(questions).toHaveLength(1); + expect(questions).toEqual([ + expect.objectContaining({ + name: 'body', + }), + ]); + }); +}); + +describe('combineCommitMessage', () => { + test('should wrap message to multi lines when max-line-length set', () => { + setRules({ + 'body-max-line-length': [RuleConfigSeverity.Error, 'always', 10], + }); + + const commitMessage = combineCommitMessage({ + body: 'This is the body message.', + }); + + expect(commitMessage).toBe('This is\nthe body\nmessage.'); + }); + + test('should auto apply leading blank', () => { + setRules({ + 'body-leading-blank': [RuleConfigSeverity.Error, 'always'], + }); + + const commitMessage = combineCommitMessage({ + body: 'This is the body message.', + }); + + expect(commitMessage).toBe('\nThis is the body message.'); + }); + + test('should return correct string when leading-blank and max-line-length both set', () => { + setRules({ + 'body-max-line-length': [RuleConfigSeverity.Error, 'always', 10], + 'body-leading-blank': [RuleConfigSeverity.Error, 'always'], + }); + const commitMessage = combineCommitMessage({ + body: 'This is the body message.', + }); + expect(commitMessage).toBe('\nThis is\nthe body\nmessage.'); + }); + + test('should use breakingBody when body message is empty but commit has BREAK CHANGE', () => { + setRules({}); + const commitMessage = combineCommitMessage({ + breakingBody: 'This is breaking body message.', + }); + expect(commitMessage).toBe('This is breaking body message.'); + }); + + test('should use issueBody when body message is empty but commit has issue note', () => { + setRules({}); + const commitMessage = combineCommitMessage({ + issuesBody: 'This is issue body message.', + }); + expect(commitMessage).toBe('This is issue body message.'); + }); +}); diff --git a/@commitlint/cz-commitlint/src/__tests__/SectionFooter.test.ts b/@commitlint/cz-commitlint/src/__tests__/SectionFooter.test.ts new file mode 100644 index 0000000000..5945a0f102 --- /dev/null +++ b/@commitlint/cz-commitlint/src/__tests__/SectionFooter.test.ts @@ -0,0 +1,314 @@ +import {RuleConfigSeverity} from '@commitlint/types'; +import {combineCommitMessage, getQuestions} from '../SectionFooter'; +import {setPromptConfig} from '../store/prompts'; +import {setRules} from '../store/rules'; + +beforeEach(() => { + setRules({}); + setPromptConfig({}); +}); +describe('getQuestions', () => { + test('should only ask questions that listed in prompt question config', () => { + setPromptConfig({ + questions: { + footer: { + description: + '