-
-
Notifications
You must be signed in to change notification settings - Fork 425
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feature: Config validation #141
Changes from all commits
4512209
5dd8f0e
2c5c2c1
4a3c6a4
e0833e2
618636f
e000ea1
ded405b
4c5a18b
de55135
00e4e28
33b0d7c
5a6e2a4
33bc21c
483edfd
596c041
de0dc17
b41c826
7df4b72
9b60a42
9b80589
226f03c
74b0035
f88f4ea
cc410ac
b5867e7
119f300
df20b61
af03543
1759bfe
131d65e
fb52ec3
44f5943
14861b2
e14c2c0
c556687
1e02b6a
f423d5c
2e273b2
366bb37
134ba6a
c697ed4
07274f5
18cc38b
0d210f4
d8f4be6
6ae9fb7
8bae467
5b35048
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1,2 @@ | ||
#!/usr/bin/env node | ||
require('./src') | ||
require('./src')() |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,26 +1,23 @@ | ||
'use strict' | ||
|
||
const path = require('path') | ||
const minimatch = require('minimatch') | ||
|
||
const readConfigOption = require('./readConfigOption') | ||
const getConfig = require('./getConfig').getConfig | ||
const resolveGitDir = require('./resolveGitDir') | ||
|
||
module.exports = function generateTasks(config, files) { | ||
const linters = config.linters !== undefined ? config.linters : config | ||
const resolve = file => files[file] | ||
const normalizedConfig = getConfig(config) // Ensure we have a normalized config | ||
const linters = normalizedConfig.linters | ||
const gitDir = normalizedConfig.gitDir | ||
const globOptions = normalizedConfig.globOptions | ||
return Object.keys(linters).map(pattern => { | ||
const commands = linters[pattern] | ||
const globOptions = readConfigOption(config, 'globOptions', {}) | ||
const filter = minimatch.filter( | ||
pattern, | ||
Object.assign( | ||
{ | ||
matchBase: true, | ||
dot: true | ||
}, | ||
globOptions | ||
) | ||
) | ||
const fileList = Object.keys(files).filter(filter).map(resolve) | ||
const filter = minimatch.filter(pattern, globOptions) | ||
const fileList = files | ||
// We want to filter before resolving paths | ||
.filter(filter) | ||
// Return absolute path after the filter is run | ||
.map(file => path.resolve(resolveGitDir(gitDir), file)) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we avoid the repeated invocation of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I assume the cost of calling is negligible so I don't see it as a problem. It helps with tests though. |
||
return { | ||
pattern, | ||
commands, | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,137 @@ | ||
/* eslint no-console: 0 */ | ||
/* eslint no-prototype-builtins: 0 */ | ||
const chalk = require('chalk') | ||
const format = require('stringify-object') | ||
const intersection = require('lodash/intersection') | ||
const defaultsDeep = require('lodash/defaultsDeep') | ||
const isObject = require('lodash/isObject') | ||
const validate = require('jest-validate').validate | ||
const logValidationWarning = require('jest-validate').logValidationWarning | ||
const unknownOptionWarning = require('jest-validate/build/warnings').unknownOptionWarning | ||
const isGlob = require('is-glob') | ||
|
||
/** | ||
* Default config object | ||
* | ||
* @type {{concurrent: boolean, chunkSize: number, gitDir: string, globOptions: {matchBase: boolean, dot: boolean}, linters: {}, subTaskConcurrency: number, renderer: string, verbose: boolean}} | ||
*/ | ||
const defaultConfig = { | ||
concurrent: true, | ||
chunkSize: Number.MAX_SAFE_INTEGER, | ||
gitDir: '.', | ||
globOptions: { | ||
matchBase: true, | ||
dot: true | ||
}, | ||
linters: {}, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Question: should we set linters to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it's better to set it to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It may be a good idea. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would suggest a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unfortunately, setting it to |
||
subTaskConcurrency: 1, | ||
renderer: 'update', | ||
verbose: false | ||
} | ||
|
||
/** | ||
* Check if the config is "simple" i.e. doesn't contains any of full config keys | ||
* | ||
* @param config | ||
* @returns {boolean} | ||
*/ | ||
function isSimple(config) { | ||
return ( | ||
isObject(config) && | ||
!config.hasOwnProperty('linters') && | ||
intersection(Object.keys(defaultConfig), Object.keys(config)).length === 0 | ||
) | ||
} | ||
|
||
/** | ||
* Custom jest-validate reporter for unknown options | ||
* @param config | ||
* @param example | ||
* @param option | ||
* @param options | ||
* @returns {void} | ||
*/ | ||
function unknownValidationReporter(config, example, option, options) { | ||
/** | ||
* If the unkonwn property is a glob this is probably | ||
* a typical mistake of mixing simple and advanced configs | ||
*/ | ||
if (isGlob(option)) { | ||
const message = ` Unknown option ${chalk.bold(`"${option}"`)} with value ${chalk.bold( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Realised this would not be correct, ignore.This fails the snapshot right now but could we change this to the following: const formattedOption = format(config[option], {
inlineCharacterLimit: Number.POSITIVE_INFINITY
})
const message = ` Unknown option ${chalk.bold(`"${option}"`)} with value ${chalk.bold(
formattedOption
)} was found in the config root.
You probably trying to mix simple and advanced config formats.
Adding
${chalk.bold(`"linters": {
"${option}": ${formattedOption}
}`)}
will fix it and remove this message.` Also, Edit: Just realised, the message printed is not valid JSON. Should be changed to the following: const message = ` Unknown option ${chalk.bold(`"${option}"`)} with value ${chalk.bold(
format(config[option], { inlineCharacterLimit: Number.POSITIVE_INFINITY })
)} was found in the config root.
You probably trying to mix simple and advanced config formats.
Adding
${chalk.bold(
`"linters": ${JSON.stringify({
[option]: config[option]
})}`
)})
will fix it and remove this message.` |
||
format(config[option], { inlineCharacterLimit: Number.POSITIVE_INFINITY }) | ||
)} was found in the config root. | ||
|
||
You are probably trying to mix simple and advanced config formats. Adding | ||
|
||
${chalk.bold(`"linters": { | ||
"${option}": ${JSON.stringify(config[option])} | ||
}`)} | ||
|
||
will fix it and remove this message.` | ||
|
||
const comment = options.comment | ||
const name = (options.title && options.title.warning) || 'WARNING' | ||
return logValidationWarning(name, message, comment) | ||
} | ||
// If it is not glob pattern, use default jest-validate reporter | ||
return unknownOptionWarning(config, example, option, options) | ||
} | ||
|
||
/** | ||
* For a given configuration object that we retrive from .lintstagedrc or package.json | ||
* construct a full configuration with all options set. | ||
* | ||
* This is a bit tricky since we support 2 different syntxes: simple and full | ||
* For simple config, only the `linters` configuration is provided. | ||
* | ||
* @param {Object} sourceConfig | ||
* @returns {{ | ||
* concurrent: boolean, chunkSize: number, gitDir: string, globOptions: {matchBase: boolean, dot: boolean}, linters: {}, subTaskConcurrency: number, renderer: string, verbose: boolean | ||
* }} | ||
*/ | ||
function getConfig(sourceConfig) { | ||
const config = defaultsDeep( | ||
{}, // Do not mutate sourceConfig!!! | ||
isSimple(sourceConfig) ? { linters: sourceConfig } : sourceConfig, | ||
defaultConfig | ||
) | ||
|
||
// Check if renderer is set in sourceConfig and if not, set accordingly to verbose | ||
if (isObject(sourceConfig) && !sourceConfig.hasOwnProperty('renderer')) { | ||
config.renderer = config.verbose ? 'verbose' : 'update' | ||
} | ||
|
||
return config | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good idea but this will throw an error every but the first time it's called, right? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think I can take this up when I add the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd suggest doing this work in a separate PR that would optimize things. Right now I think it's important to merge this. The goal is to implement better config validation and right now I feel like digging a rabbit hole :) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ha ha.. okay. I'll try to stay on-topic. |
||
} | ||
|
||
/** | ||
* Runs config validation. Throws error if the config is not valid. | ||
* @param config {Object} | ||
* @returns config {Object} | ||
*/ | ||
function validateConfig(config) { | ||
const exampleConfig = Object.assign({}, defaultConfig, { | ||
linters: { | ||
'*.js': ['eslint --fix', 'git add'], | ||
'*.css': 'stylelint' | ||
} | ||
}) | ||
|
||
const validation = validate(config, { | ||
exampleConfig, | ||
unknown: unknownValidationReporter, | ||
comment: | ||
'Please refer to https://github.com/okonet/lint-staged#configuration for more information...' | ||
}) | ||
|
||
if (!validation.isValid) { | ||
throw new Error('lint-staged config is invalid... Aborting.') | ||
} | ||
|
||
return config | ||
} | ||
|
||
module.exports = { | ||
getConfig, | ||
validateConfig | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,104 +1,63 @@ | ||
/* eslint no-console: 0 */ | ||
/* eslint no-process-exit: 0 */ | ||
/* eslint import/no-dynamic-require: 0 */ | ||
|
||
'use strict' | ||
|
||
const path = require('path') | ||
const sgf = require('staged-git-files') | ||
const appRoot = require('app-root-path') | ||
const Listr = require('listr') | ||
const cosmiconfig = require('cosmiconfig') | ||
const stringifyObject = require('stringify-object') | ||
const getConfig = require('./getConfig').getConfig | ||
const validateConfig = require('./getConfig').validateConfig | ||
const printErrors = require('./printErrors') | ||
const runAll = require('./runAll') | ||
|
||
const packageJson = require(appRoot.resolve('package.json')); // eslint-disable-line | ||
const runScript = require('./runScript') | ||
const generateTasks = require('./generateTasks') | ||
const readConfigOption = require('./readConfigOption') | ||
// Find the right package.json at the root of the project | ||
// TODO: Test if it should be aware of `gitDir` | ||
const packageJson = require(appRoot.resolve('package.json')) | ||
|
||
// Force colors for packages that depend on https://www.npmjs.com/package/supports-color | ||
// but do this only in TTY mode | ||
if (process.stdout.isTTY) { | ||
process.env.FORCE_COLOR = true | ||
} | ||
|
||
cosmiconfig('lint-staged', { | ||
rc: '.lintstagedrc', | ||
rcExtensions: true | ||
}) | ||
.then(result => { | ||
// result.config is the parsed configuration object | ||
// result.filepath is the path to the config file that was found | ||
const config = result.config | ||
|
||
const verbose = config.verbose | ||
// Output config in verbose mode | ||
if (verbose) console.log(config) | ||
const concurrent = readConfigOption(config, 'concurrent', true) | ||
const renderer = verbose ? 'verbose' : 'update' | ||
const gitDir = config.gitDir ? path.resolve(config.gitDir) : process.cwd() | ||
sgf.cwd = gitDir | ||
|
||
sgf('ACM', (err, files) => { | ||
if (err) { | ||
console.error(err) | ||
process.exit(1) | ||
/** | ||
* Root lint-staged function that is called from .bin | ||
*/ | ||
module.exports = function lintStaged() { | ||
cosmiconfig('lint-staged', { | ||
rc: '.lintstagedrc', | ||
rcExtensions: true | ||
}) | ||
.then(result => { | ||
// result.config is the parsed configuration object | ||
// result.filepath is the path to the config file that was found | ||
const config = validateConfig(getConfig(result.config)) | ||
|
||
if (config.verbose) { | ||
console.log(` | ||
Running lint-staged with the following config: | ||
${stringifyObject(config)} | ||
`) | ||
} | ||
|
||
const resolvedFiles = {} | ||
files.forEach(file => { | ||
const absolute = path.resolve(gitDir, file.filename) | ||
const relative = path.relative(gitDir, absolute) | ||
resolvedFiles[relative] = absolute | ||
}) | ||
|
||
const tasks = generateTasks(config, resolvedFiles).map(task => ({ | ||
title: `Running tasks for ${task.pattern}`, | ||
task: () => | ||
new Listr( | ||
runScript(task.commands, task.fileList, packageJson, { | ||
gitDir, | ||
verbose, | ||
config | ||
}), | ||
{ | ||
// In sub-tasks we don't want to run concurrently | ||
// and we want to abort on errors | ||
concurrent: false, | ||
exitOnError: true | ||
} | ||
), | ||
skip: () => { | ||
if (task.fileList.length === 0) { | ||
return `No staged files match ${task.pattern}` | ||
} | ||
return false | ||
} | ||
})) | ||
|
||
if (tasks.length) { | ||
new Listr(tasks, { | ||
concurrent, | ||
renderer, | ||
exitOnError: !concurrent // Wait for all errors when running concurrently | ||
runAll(packageJson, config) | ||
.then(() => { | ||
// No errors, exiting with 0 | ||
process.exitCode = 0 | ||
}) | ||
.catch(error => { | ||
// Errors detected, printing and exiting with non-zero | ||
printErrors(error) | ||
process.exitCode = 1 | ||
}) | ||
.run() | ||
.catch(error => { | ||
if (Array.isArray(error.errors)) { | ||
error.errors.forEach(lintError => { | ||
console.error(lintError.message) | ||
}) | ||
} else { | ||
console.log(error.message) | ||
} | ||
process.exit(1) | ||
}) | ||
} | ||
}) | ||
}) | ||
.catch(parsingError => { | ||
console.error(`Could not parse lint-staged config. | ||
.catch(parsingError => { | ||
console.error(`Could not parse lint-staged config. | ||
Make sure you have created it. See https://github.com/okonet/lint-staged#readme. | ||
|
||
${parsingError} | ||
`) | ||
process.exit(1) | ||
}) | ||
process.exitCode = 1 | ||
}) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
/* eslint no-console: 0 */ | ||
|
||
'use strict' | ||
|
||
module.exports = function printErrors(errorInstance) { | ||
if (Array.isArray(errorInstance.errors)) { | ||
errorInstance.errors.forEach(lintError => { | ||
console.error(lintError.message) | ||
}) | ||
} else { | ||
console.error(errorInstance.message) | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we avoid repeatedly executing
getConfig
? I suggest we set a property_normalized
totrue
which can be used in the following ways:getConfig
.runAll
) and throw an error if not.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good suggestions. You want to work on this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
😄 Okay. I usually have to hunt around for finding open source work. Believe it or not, I am very grateful to you for sending some work my way.