UNPKG

commit-msg

Version:
486 lines (417 loc) 16.7 kB
'use strict'; var path = require('path'); var fs = require('fs'); var util = require('util'); var semver = require('semver'); var Error = require('./error'); var Reference = require('./reference'); var Parser = require('./nlp-parser'); var Config = require('./config'); /** * Commit message class * * @param {String} message The commit message * @constructor */ function CommitMessage(message, config) { this._message = message; this._config = config; } // Properties Object.defineProperties(CommitMessage.prototype, { message: { get: function() { return this._message; } }, formattedMessages: { get: function() { var msgs = []; this._errors.forEach(function(e) { msgs.push(e.toFormattedString()); }); return msgs.join( "\n" ); } } }); // Static methods (initializers) CommitMessage.parse = function(message, config, cb) { if (!cb) { cb = config; config = undefined; } CommitMessage.resolveConfig(config, function(cfg) { var msg = new CommitMessage(message, Config( cfg )); msg.validate(cb); }); } CommitMessage.parseFromFile = function(file, config, cb) { if (!cb) { cb = config; config = undefined; } fs.readFile(file, { encoding: 'utf8' }, function(err, msg) { if (err) { cb(err); return; }; CommitMessage.parse(msg, config, cb); }); } CommitMessage.Error = Error; CommitMessage.Config = Config; // Possible types for 'cfg': undefined, object, true, string. // If undefined, it will use the default validations, // if object, it will overwrite the default validations, // if true or string it will search for the first package.json starting // at the current dir (true) or the given path (string) and // use the 'commitMsg' key from it, if any. CommitMessage.resolveConfig = function(cfg, cb) { var getPackageJsonConfig = function(dir) { if (!dir) { return cb(false); } var file = path.resolve(dir, 'package.json'); // check if exists, not with fs.exists() since it's deprecated fs.open(file, 'r', function(err, fd) { if (!err) { var pkg = require(file); cb(pkg.commitMsg); fs.close(fd, function(){}); } else { var upDir = path.resolve(dir, '..'); getPackageJsonConfig(upDir != dir ? upDir : null); } }); } if (cfg && typeof(cfg) != 'object') { // try to find the first package.json file with a config getPackageJsonConfig(typeof(cfg) == 'string' ? cfg : __dirname); } else { cb(cfg); } } // See the async method above for details. CommitMessage.resolveConfigSync = function(cfg) { if (cfg && typeof(cfg) != 'object') { var dir = typeof(cfg) == 'string' ? cfg : __dirname; while(dir) { var file = path.resolve(dir, 'package.json'); try { if (fs.statSync(file).isFile()) { var pkg = require(file); return pkg.commitMsg; } } catch (e) {} var upDir = path.resolve(dir, '..'); dir = upDir != dir ? upDir : null; } return false; } return cfg; } // Methods CommitMessage.prototype.validate = function(cb) { this._errors = []; var cfg = this._config; if (cfg.disable) { return cb(null, this); // validation is disabled } var message = this._message; // remove all comments and everything below 'the' line message = message.replace(/^#( ------------------------ >8 ------------------------[\s\S]*|.*\n*)/gm, ''); if (!message.trim()) { this._subject = ''; return cb(null, this); // empty commit messages are allowed } if (!cfg.pattern.test(message)) { this._log('Commit message is not in the correct format; subject (first line) ' + 'and body should be separated by one empty line', Error.ERROR); return cb(null, this); // can't continue past this point } var matches = message.match(cfg.pattern); var s = this._subject = matches[1].replace(/[\s]*$/, ""); // remove any trailing whitespace var effectiveS = s; // used for checking the length var b = this._body = matches[2]; if (cfg.squashFixup) { s = effectiveS = s.replace(/^(squash|fixup)! ?/, function(match, type, offset, string) { this._isSquash = type == 'squash'; this._isFixup = type == 'fixup'; return ''; }.bind(this)); if (cfg.squashFixup.allow && (!cfg.squashFixup.validateSquash && this._isSquash || !cfg.squashFixup.validateFixup && this._isFixup)) { return cb(null, this); // skip validation for squash! and/or fixup! commits } } if (cfg.types) { s = s.replace(cfg.types.allowedTypes, ''); } this._isSemver = semver.valid(s); // Validations if (!this._checkSquashFixup(s)) { // stop validation because it's pointless return cb(null, this); } s = this._checkStrictTypes(s); this._colOffset = this._subject.length - s.length; this._checkCapitalLetter(s); this._checkLength(effectiveS); this._checkWhitespace(s); this._checkEnding(s); this._checkInvalidCharacters(s); this._checkReferences(message, function(err) { if (err) return cb(err); this._checkImperativeVerbs(s, function(err) { if (err) return cb(err); if (b) { this._checkLengthInBody(b); } cb(null, this); // finish }.bind(this)); }.bind(this)); } CommitMessage.prototype.hasErrors = function() { return !this._errors.every(function(e) { return !e.is(Error.ERROR); }); } CommitMessage.prototype.hasWarnings = function() { return !this._errors.every(function(e) { return !e.is(Error.WARNING); }); } // Private CommitMessage.prototype._log = function(msg, type, location) { this._errors.push( new Error(msg, type, location) ); } // Validation methods // Return true if ok CommitMessage.prototype._checkSquashFixup = function(s) { var cfg = this._config; var errs = this._errors.length; if (cfg.squashFixup && !cfg.squashFixup.allow) { if (this._isSquash) { this._log('squash! commits are not allowed', cfg.squashFixup.type, [1, 1]); } if (this._isFixup) { this._log('fixup! commits are not allowed', cfg.squashFixup.type, [1, 1]); } } return errs === this._errors.length; } CommitMessage.prototype._checkTypes = function() { var cfg = this._config; if (cfg.types && cfg.types.required && !cfg.types.allowedTypes.test(this._subject)) { this._log('Commit subject should be prefixed by a type', cfg.types.type, [1, 1]); } } CommitMessage.prototype._checkStrictTypes = function(s) { var cfg = this._config; if (cfg.strictTypes && cfg.strictTypes.invalidTypes.test(s)) { this._log(util.format('Commit subject contains invalid type %s', s.match(cfg.strictTypes.invalidTypes)[1].trim()), cfg.strictTypes.type, [1, 1]); s = s.replace(cfg.strictTypes.invalidTypes, ''); } else { // only check this if no invalid type was detected this._checkTypes(); } return s; } CommitMessage.prototype._checkCapitalLetter = function(s) { var cfg = this._config; if (!this._isSemver && cfg.capitalized) { if (cfg.capitalized.capital && !/^[A-Z]/.test(s)) { this._log('Commit message should start with a capitalized letter', cfg.capitalized.type, [1, 1+this._colOffset]); } else if (!cfg.capitalized.capital && !/^[a-z]/.test(s)) { this._log('Commit message should start with a lowercase letter', cfg.capitalized.type, [1, 1+this._colOffset]); } } } CommitMessage.prototype._checkLength = function(s) { var cfg = this._config; if (cfg.subjectMaxLineLength && s.length > cfg.subjectMaxLineLength.length) { this._log(util.format('Commit subject should not exceed %d characters', cfg.subjectMaxLineLength.length), cfg.subjectMaxLineLength.type, [1, cfg.subjectMaxLineLength.length]); } else if (cfg.subjectPreferredMaxLineLength && s.length > cfg.subjectPreferredMaxLineLength.length) { this._log(util.format('Commit subject should not exceed %d characters', cfg.subjectPreferredMaxLineLength.length), cfg.subjectPreferredMaxLineLength.type, [1, cfg.subjectPreferredMaxLineLength.length]); } } CommitMessage.prototype._checkLengthInBody = function(b) { var cfg = this._config; if (cfg.bodyMaxLineLength) { var max = cfg.bodyMaxLineLength.length; var lines = b.split(/\n/); var longer = []; for (var i=0; i<lines.length; i++) { var line = lines[i]; if (line.length > max) { longer.push(i+1); } } var c; if (c = longer.length) { var exceptFor = cfg.bodyMaxLineLength.type === Error.ERROR ? '' : ', except for compiler error messages or other "non-prose" explanation'; if (c <= 3) { this._log(util.format('Line(s) %s in the commit body are ' + 'longer than %d characters. Body lines should ' + 'not exceed %d characters%s' , longer.join(', '), max, max, exceptFor), cfg.bodyMaxLineLength.type, [longer[0]+2, max]); } else { this._log(util.format('There are %d lines in the commit body ' + 'that are longer than %d characters. Body lines should ' + 'not exceed %d characters%s' , c, max, max, exceptFor), cfg.bodyMaxLineLength.type, [longer[0]+2, max]); } } } } CommitMessage.prototype._checkWhitespace = function(s) { var index; if ((index = s.search(/\s\s/)) !== -1) { this._log('Commit subject contains invalid whitespace', Error.ERROR, [1, index+1+this._colOffset]); } } CommitMessage.prototype._checkEnding = function(s) { if (/[.]$/.test(s)) { this._log('Commit subject should not end with a period', Error.ERROR, [1, s.length+this._colOffset]); } } CommitMessage.prototype._checkInvalidCharacters = function(s) { var cfg = this._config; var index; if (cfg.invalidCharsInSubject && (index = s.search(cfg.invalidCharsInSubject.invalidChars)) !== -1) { this._log('Commit subject contains invalid characters', cfg.invalidCharsInSubject.type, [1, index+1+this._colOffset]); } } CommitMessage.prototype._checkImperativeVerbs = function(s, cb) { var cfg = this._config; var index; if (cfg.imperativeVerbsInSubject && ( cfg.imperativeVerbsInSubject.alwaysCheck || !this.hasErrors() )) { Parser.parseSentences([s], function(err, parsers) { if (err) return cb(err, this); parsers.every(function(parser) { var hasError = false; if (!parser.isFragment() && parser.hasVerb()) { // If the commit message is not a fragment and // contains at least 1 verb, continue var checkImperative = function(verb) { var matches = verb.value.match(/^VB[^P\s]+ (\S+)$/); if (matches) { var index = s.indexOf(matches[1]); this._log('Use imperative present tense, eg. "Fix bug" not ' + '"Fixed bug" or "Fixes bug". To get it right ask yourself: "If applied, ' + 'this patch will <YOUR-COMMIT-MESSAGE-HERE>"', cfg.imperativeVerbsInSubject.type, [1, index+1+this._colOffset]); hasError = true; return false; // stop loop } return true; }; try { var baseNode = parser.penn.getHighestLevelNodesWithValue(/^VBP?/)[0].parent.parent; } catch(e) {} // children of baseNode need to have the first verb in imperative mood if (baseNode) { baseNode.children.every(function(child) { var verbs = child.getHighestLevelNodesWithValue(/^VB/); return verbs.every(checkImperative, this); }, this); } } return !hasError; }, this); cb(null, this); }.bind(this)); return; } cb(null, this); } CommitMessage.prototype._checkReferences = function(message, callback) { var cfg = this._config; var index; var column; var ref; var errMsg; var validRefNames = []; var invalidRefErrors = {}; // name: Error var cb = function() { for (var r in invalidRefErrors) { if (invalidRefErrors.hasOwnProperty(r)) { this._errors.push(invalidRefErrors[r]); } } callback.apply(this, arguments); }.bind(this); if (cfg.references) { Reference.parse(message, cfg, function(err, refs) { if (err) return cb(err); index = 0; var processRef = function() { ref = refs[index]; if (!ref) { return cb(null, this); // done processing refs } var getLineIndex = function() { var lineIndex; message.split('\n').every(function(line, idx) { if ((column = line.indexOf(ref.match)) !== -1) { lineIndex = idx; return false; } return true; }); return lineIndex; } if (!ref.allowInSubject) { // pull requests can appear in the subject (eg. added by GitHub) // https://developer.mozilla.org/en/docs/Web/JavaScript/Guide/Regular_Expressions var escaped = ref.match.replace(/[.*+?^${}()|[\]\\\/]/g, "\\$&"); var p = new RegExp('('+escaped+'.*\n\n|^[^\n]*'+escaped+')'); if (p.test(message.trim())) { this._log('References should be placed in the last paragraph of the body', cfg.references.type, [getLineIndex()+1, column+1]); return cb(null, this); } } if (cfg.references.alwaysCheck !== 'never' && ( cfg.references.alwaysCheck || !this.hasErrors() )) { // only access the API if no other errors detected or we must if (validRefNames.indexOf(ref.toString()) === -1) { // validate ref only if not valid already (when using multiple references) ref.isValid(function(err, valid) { if (err) return cb(err); if (!valid) { errMsg = ref.error ? ref.error.message : util.format('Reference %s is not valid', ref.toString()); var line = getLineIndex(); line = isNaN(line) ? undefined : (line+1); var col = column==-1 ? undefined : (column+1); invalidRefErrors[ref.toString()] = new Error(errMsg, cfg.references.type, [line, col]); } else { validRefNames.push(ref.toString()); delete invalidRefErrors[ref.toString()]; } index++; processRef(); }.bind(this)); } else { index++; processRef(); } } else { cb(null, this); } }.bind(this); processRef(); }.bind(this)); return; } cb(null, this); } module.exports = CommitMessage;