Skip to content
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

Merged
merged 49 commits into from
Sep 4, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
4512209
tests: Update eslintrc to not fail on Jest
okonet Mar 16, 2017
5dd8f0e
tests: Add resolveGitDir and tests and refactor generateTasks
okonet Mar 16, 2017
2c5c2c1
tests: Introduce getConfig + tests
okonet Mar 17, 2017
4a3c6a4
tests: Refactor index.js in a way we can test the output
okonet Mar 17, 2017
e0833e2
tests: Do not use async/await
okonet Mar 17, 2017
618636f
tests: Simplify and fix the implementation of getConfig
okonet May 11, 2017
e000ea1
Refactor code to use getConfig
okonet May 11, 2017
ded405b
Update runAll and tests
okonet May 11, 2017
4c5a18b
tests: Updated Jest snapshots
okonet May 11, 2017
de55135
tests: Serialize stdout into string instead of Array
okonet Jun 9, 2017
00e4e28
Merge branch 'master' into more-tests
Aug 29, 2017
33b0d7c
Reformat code
Aug 29, 2017
5a6e2a4
Fix merge mistake
Aug 29, 2017
33bc21c
Update snapshot
Aug 29, 2017
483edfd
Fix and skip some tests
Aug 29, 2017
596c041
tests: Fixed weird tests bug when config.gitRoot was affecting other …
Aug 29, 2017
de0dc17
Removed .only from test suite
Aug 29, 2017
b41c826
fixup! Removed .only from test suite
Aug 29, 2017
7df4b72
Deeply merge config
Aug 29, 2017
9b60a42
Add chunkSize and subTaskConcurrency properties to default config and…
Aug 30, 2017
9b80589
Remove readConfigOption and use getConfig instead
Aug 30, 2017
226f03c
Use stringify-object when printing config in verbos mode.
Aug 30, 2017
74b0035
Merge branch 'master' into more-tests
Aug 30, 2017
f88f4ea
chore: Add missing jest-cli dev dependency
Aug 30, 2017
cc410ac
fix: Make code Node 4.x compatible
Aug 30, 2017
b5867e7
Revert "chore: Add missing jest-cli dev dependency"
Aug 31, 2017
119f300
chore(log): Add printErrors function + tests
Aug 31, 2017
df20b61
refactor: Move error logging code from runAll to index.js and use pro…
Aug 31, 2017
af03543
style: Fix typo
Aug 31, 2017
1759bfe
chore: Remove lodash.chunk dependency
Aug 31, 2017
131d65e
style: Fix typo
Aug 31, 2017
fb52ec3
Revert "Revert "chore: Add missing jest-cli dev dependency""
Aug 31, 2017
44f5943
fix: Do not populate simple config keys and values into full config
Aug 31, 2017
14861b2
tests: Remove dependency on expect and is-promise and use Jest
Aug 31, 2017
e14c2c0
tests: Move config loggin to the root index function and add more tes…
Aug 31, 2017
c556687
tests: Simplify and remove unused tests
Aug 31, 2017
1e02b6a
feat: Implement config validation
Sep 1, 2017
f423d5c
style: Pass full config to runAll and validate it before running
Sep 1, 2017
2e273b2
chore: Upgrade Jest
Sep 1, 2017
366bb37
tests: Set process.stdout.TTY to false to fix tests on CI
Sep 1, 2017
134ba6a
tests: Globally set `process.stdout.isTTY=false` to ensure tests don'…
Sep 1, 2017
c697ed4
fix: Split getConfig and config validation and run validation once
Sep 1, 2017
07274f5
fix: Remove new line in the vaslidation reporter
Sep 2, 2017
18cc38b
fix: Print valid JSON in the warning message. Fix grammar.
Sep 2, 2017
0d210f4
fix: Fix indentation and return after glob warning
Sep 4, 2017
d8f4be6
tests: Add test for valid advanced config
Sep 4, 2017
6ae9fb7
tests: Better test for immutability
Sep 4, 2017
8bae467
style: Better require
Sep 4, 2017
5b35048
tests: Update test titles
Sep 4, 2017
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion index.js
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
#!/usr/bin/env node
require('./src')
require('./src')()
20 changes: 13 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
},
"pre-commit": "pre-commit",
"jest": {
"testEnvironment": "node"
"testEnvironment": "node",
"setupFiles": ["./testSetup.js"]
},
"greenkeeper": {
"ignore": [
Expand Down Expand Up @@ -55,31 +56,36 @@
"homepage": "https://github.com/okonet/lint-staged#readme",
"dependencies": {
"app-root-path": "^2.0.0",
"chalk": "^2.1.0",
"cosmiconfig": "^1.1.0",
"execa": "^0.8.0",
"is-glob": "^4.0.0",
"jest-validate": "^20.0.3",
"listr": "^0.12.0",
"lodash.chunk": "^4.2.0",
"lodash": "^4.17.4",
"minimatch": "^3.0.0",
"npm-which": "^3.0.1",
"p-map": "^1.1.1",
"staged-git-files": "0.0.4"
"staged-git-files": "0.0.4",
"stringify-object": "^3.2.0"
},
"devDependencies": {
"babel-jest": "^20.0.0",
"babel-preset-env": "^1.6.0",
"commitizen": "^2.9.6",
"consolemock": "^0.2.1",
"cz-conventional-changelog": "^2.0.0",
"eslint": "^4.5.0",
"eslint-config-okonet": "^5.0.1",
"eslint-plugin-node": "^5.1.1",
"expect": "^1.20.2",
"is-promise": "^2.1.0",
"jest": "^20.0.1",
"jest": "^20.0.4",
"jest-cli": "^20.0.4",
"jsonlint": "^1.6.2",
"npm-check": "^5.2.2",
"pre-commit": "^1.1.3",
"prettier": "1.5.3",
"remove-lockfiles": "^1.1.1"
"remove-lockfiles": "^1.1.1",
"strip-ansi": "^3.0.1"
},
"config": {
"commitizen": {
Expand Down
29 changes: 13 additions & 16 deletions src/generateTasks.js
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
Copy link
Collaborator

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 to true which can be used in the following ways:

  • Use the property for an early return in getConfig.
  • Check that the config has been normalized in all places other than the first one(runAll) and throw an error if not.

Copy link
Collaborator Author

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?

Copy link
Collaborator

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.

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))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we avoid the repeated invocation of resolveGitDir(gitDir)?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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,
Expand Down
137 changes: 137 additions & 0 deletions src/getConfig.js
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: {},
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: should we set linters to null or undefined initially so that the validation fails on it (making them a required property)?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's better to set it to undefined

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may be a good idea.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest a lodash#isEmpty check because that also validates an empty config object.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately, setting it to undefined doesn't make jest-validate to fail the validation. I'm not sure there is way to do so using it. This can be improved afterwards I think.

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(
Copy link
Collaborator

@sudo-suhas sudo-suhas Sep 2, 2017

Choose a reason for hiding this comment

The 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, You probably trying ➡ You are probably trying.

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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Object.freeze? Or is it overkill?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I can take this up when I add the _normalize flag.

Copy link
Collaborator Author

@okonet okonet Aug 31, 2017

Choose a reason for hiding this comment

The 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 :)

Copy link
Collaborator

Choose a reason for hiding this comment

The 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
}
123 changes: 41 additions & 82 deletions src/index.js
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
})
}
13 changes: 13 additions & 0 deletions src/printErrors.js
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)
}
}
Loading