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

Run linters with configurable concurrency #149

Merged
merged 4 commits into from
May 25, 2017
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
15 changes: 14 additions & 1 deletion .babelrc
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
{
"presets": ["es2015", "stage-0"]
"presets": [
["env", {
"targets": {
"node": "current"
}
}]
],
"plugins": [
["transform-runtime", {
"helpers": false,
"polyfill": false,
"regenerator": true
}]
]
}
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,19 @@
"cosmiconfig": "^1.1.0",
"execa": "^0.6.0",
"listr": "^0.12.0",
"lodash.chunk": "^4.2.0",
"minimatch": "^3.0.0",
"npm-which": "^3.0.1",
"p-map": "^1.1.1",
"staged-git-files": "0.0.4"
},
"devDependencies": {
"babel-core": "^6.10.4",
"babel-jest": "^20.0.0",
"babel-preset-es2015": "^6.9.0",
"babel-preset-stage-0": "^6.5.0",
"babel-plugin-transform-runtime": "^6.23.0",
"babel-preset-env": "^1.4.0",
"babel-register": "^6.16.3",
"babel-runtime": "^6.23.0",
"cz-conventional-changelog": "^1.2.0",
"eslint": "^3.9.1",
"eslint-config-okonet": "^1.1.1",
Expand Down
42 changes: 42 additions & 0 deletions src/calcChunkSize.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
'use strict'

/**
* Calculates and returns the chunk size for given file paths and `chunkSize`
* option.
*
* It returns the minimum of the following:
*
* - Total number of files
* - Max allowed chunk size so that command length does not exceed the system
* limitation on windows
* - User specified chunk size or the default
*
* Worked example:
* **Assumption** - Our max file path length is 100, Hence max allowed chunk
* size is 80
*
* - Case 1: Only 10 files are there, chunk size should be 10 only
* - Case 2: There are 100 files and user has overridden the option with
* chunk size 40. So chunk size should be 40
* - Case 3: There are 100 files and user has overridden the option with
* chunk size 100. So chunk size should be 80
*
* @param {Array<string>} paths The array of file paths
* @param {number} idealChunkSize User specified / default chunk size
* @returns {number} The chunk size
*/
module.exports = function calcChunkSize(paths, idealChunkSize) {
/* What is the longest file path? */
const maxPathLen = paths.reduce(
(maxLen, filePath) => Math.max(maxLen, filePath.length),
20 // safe initial value
)

/* In the worst case scenario, */
/* how many files can we process in a single command? */
/* For windows systems, command length is limited to 8192 */
const maxAllowedChunkSize = Math.floor(8000 / maxPathLen)

/* Configured chunk size / default - idealChunkSize */
return Math.min(paths.length, maxAllowedChunkSize, idealChunkSize)
}
25 changes: 9 additions & 16 deletions src/findBin.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,20 @@

const npmWhich = require('npm-which')(process.cwd())

module.exports = function findBin(cmd, paths, packageJson, options) {
const defaultArgs = ['--'].concat(paths)
module.exports = function findBin(cmd, packageJson, options) {
/*
* If package.json has script with cmd defined
* we want it to be executed first
*/
if (packageJson.scripts && packageJson.scripts[cmd] !== undefined) {
// Support for scripts from package.json
return {
bin: 'npm',
args: [
'run',
options && options.verbose ? undefined : '--silent',
cmd
]
.filter(Boolean)
.concat(defaultArgs)
}
const args = [
'run',
options && options.verbose ? undefined : '--silent',
cmd
].filter(Boolean)

return { bin: 'npm', args }
}

/*
Expand Down Expand Up @@ -52,8 +48,5 @@ module.exports = function findBin(cmd, paths, packageJson, options) {
throw new Error(`${ bin } could not be found. Try \`npm install ${ bin }\`.`)
}

return {
bin,
args: args.concat(defaultArgs)
}
return { bin, args }
}
6 changes: 4 additions & 2 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const cosmiconfig = require('cosmiconfig')
const packageJson = require(appRoot.resolve('package.json')) // eslint-disable-line
const runScript = require('./runScript')
const generateTasks = require('./generateTasks')
const readConfigOption = require('./readConfigOption')

// Force colors for packages that depend on https://www.npmjs.com/package/supports-color
// but do this only in TTY mode
Expand All @@ -27,10 +28,11 @@ cosmiconfig('lint-staged', {
// 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 = typeof config.concurrent !== 'undefined' ? config.concurrent : true
const concurrent = readConfigOption(config, 'concurrent', true)
const renderer = verbose ? 'verbose' : 'update'
const gitDir = config.gitDir ? path.resolve(config.gitDir) : process.cwd()
sgf.cwd = gitDir
Expand All @@ -56,7 +58,7 @@ cosmiconfig('lint-staged', {
task.commands,
task.fileList,
packageJson,
{ gitDir, verbose }
{ gitDir, verbose, config }
), {
// In sub-tasks we don't want to run concurrently
// and we want to abort on errors
Expand Down
20 changes: 20 additions & 0 deletions src/readConfigOption.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
'use strict'

/**
* Helper function for reading config option.
* Returns the `config` option for given `key` or the given `defaultValue`
* if `config` does not have the given `key`.
*
* @param {Object} config
* @param {string} key
* @param {*} defaultValue
* @returns {*}
*/
module.exports = function readConfigOption(config, key, defaultValue) {
if (typeof config !== 'undefined' && typeof config[key] !== 'undefined') {
return config[key]
}

return defaultValue
}

61 changes: 49 additions & 12 deletions src/runScript.js
Original file line number Diff line number Diff line change
@@ -1,33 +1,70 @@
'use strict'

const findBin = require('./findBin')
const chunk = require('lodash.chunk')
const execa = require('execa')
const pMap = require('p-map')

const calcChunkSize = require('./calcChunkSize')
const findBin = require('./findBin')
const readConfigOption = require('./readConfigOption')

module.exports = function runScript(commands, pathsToLint, packageJson, options) {
const config = readConfigOption(options, 'config', {})

const concurrency = readConfigOption(config, 'subTaskConcurrency', 2)
const chunkSize = calcChunkSize(
pathsToLint,
readConfigOption(config, 'chunkSize', Number.MAX_SAFE_INTEGER)
)

const filePathChunks = chunk(pathsToLint, chunkSize)

const lintersArray = Array.isArray(commands) ? commands : [commands]

return lintersArray.map(linter => ({
title: linter,
task: () => {
try {
const res = findBin(linter, pathsToLint, packageJson, options)
const res = findBin(linter, packageJson, options)

// Only use gitDir as CWD if we are using the git binary
// e.g `npm` should run tasks in the actual CWD
const execaOptions =
res.bin.endsWith('git') && options && options.gitDir ? { cwd: options.gitDir } : {}
return new Promise((resolve, reject) => {
execa(res.bin, res.args, execaOptions)
.then(() => {
resolve(`✅ ${ linter } passed!`)
})
res.bin.endsWith('git') && options && options.gitDir
? { cwd: options.gitDir } : {}

const errors = []
const mapper = (pathsChunk) => {
const args = res.args.concat(['--'], pathsChunk)

return execa(res.bin, args, Object.assign({}, execaOptions))
/* If we don't catch, pMap will terminate on first rejection */
/* We want error information of all chunks */
.catch((err) => {
reject(new Error(`🚫 ${ linter } found some errors. Please fix them and try committing again.
${ err.stderr }
${ err.stdout }`))
errors.push(err)
})
})
}

return pMap(filePathChunks, mapper, { concurrency })
.catch((err) => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

This isn't covered with tests. Should we add one?

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'd have to add a mock right?

/* This will probably never be called. But just in case.. */
throw new Error(`🚫 ${ linter } got an unexpected error.
${ err.message }`)
})
.then(() => {
if (errors.length === 0) return `✅ ${ linter } passed!`

const errStdout = errors.map(err => err.stdout).join('')
const errStderr = errors.map(err => err.stderr).join('')

throw new Error(`🚫 ${ linter } found some errors. Please fix them and try committing again.
${ errStdout }
${ errStderr }`)
})
} catch (err) {
throw err
}
}
}))
}

27 changes: 27 additions & 0 deletions test/calcChunkSize.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import calcChunkSize from '../src/calcChunkSize'

// This is only ever used for length so the contents do not matter much
const testFilePath =
'This-is-only-ever-used-for-length-so-the-contents-do-not-matter-much.length-is-100-for-simplicity.js'

describe('calcChunkSize', () => {
it('should not return high chunk size for less files', () => {
let chunkSize = calcChunkSize([testFilePath], 50)
expect(chunkSize).toEqual(1)

chunkSize = calcChunkSize([testFilePath, testFilePath], 50)
expect(chunkSize).toEqual(2)
})

it('should not return chunk size which will fail max command length', () => {
const fakeFilePaths = Array(200).fill(testFilePath)
const chunkSize = calcChunkSize(fakeFilePaths, Number.MAX_SAFE_INTEGER)
expect(chunkSize).toEqual(80)
})

it('should respect option chunkSize where ever possible', () => {
const fakeFilePaths = Array(200).fill(testFilePath)
const chunkSize = calcChunkSize(fakeFilePaths, 50)
expect(chunkSize).toEqual(50)
})
})
18 changes: 9 additions & 9 deletions test/findBin.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ describe('findBin', () => {
'my-linter': 'my-linter'
}
}
const { bin, args } = findBin('my-linter', 'test.js', packageJSONMock)
const { bin, args } = findBin('my-linter', packageJSONMock)
expect(bin).toEqual('npm')
expect(args).toEqual(['run', '--silent', 'my-linter', '--', 'test.js'])
expect(args).toEqual(['run', '--silent', 'my-linter'])
})

it('should return npm run command without --silent in verbose mode', () => {
Expand All @@ -29,27 +29,27 @@ describe('findBin', () => {
eslint: 'eslint'
}
}
const { bin, args } = findBin('eslint', 'test.js', packageJSONMock, { verbose: true })
const { bin, args } = findBin('eslint', packageJSONMock, { verbose: true })
expect(bin).toEqual('npm')
expect(args).toEqual(['run', 'eslint', '--', 'test.js'])
expect(args).toEqual(['run', 'eslint'])
})

it('should return path to bin if there is no `script` with name in package.json', () => {
const { bin, args } = findBin('my-linter', 'test.js test2.js', packageJSON)
const { bin, args } = findBin('my-linter', packageJSON)
expect(bin).toEqual('my-linter')
expect(args).toEqual(['--', 'test.js test2.js'])
expect(args).toEqual([])
})

it('should throw an error if bin not found and there is no entry in scripts section', () => {
expect(() => {
findBin('my-missing-linter', 'test.js', packageJSON)
findBin('my-missing-linter', packageJSON)
}).toThrow('my-missing-linter could not be found. Try `npm install my-missing-linter`.')
})


it('should parse cmd and add arguments to args', () => {
const { bin, args } = findBin('my-linter task --fix', 'test.js test2.js', packageJSON)
const { bin, args } = findBin('my-linter task --fix', packageJSON)
expect(bin).toEqual('my-linter')
expect(args).toEqual(['task', '--fix', '--', 'test.js test2.js'])
expect(args).toEqual(['task', '--fix'])
})
})
20 changes: 20 additions & 0 deletions test/readConfigOption.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import readConfigOption from '../src/readConfigOption'

describe('readConfigOption', () => {

it('should return default value if config is undefined', () => {
const configOption = readConfigOption(undefined, 'my_key', 'default_value')
expect(configOption).toEqual('default_value')
})

it('should return default value if config option is undefined', () => {
const configOption = readConfigOption({}, 'my_key', 'default_value')
expect(configOption).toEqual('default_value')
})

it('should return config option if not undefined', () => {
const configOption = readConfigOption({ my_key: 'my_value' }, 'my_key', 'default_value')
expect(configOption).toEqual('my_value')
})

})
14 changes: 8 additions & 6 deletions test/runScript-mock-findBin.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ import runScript from '../src/runScript'
jest.mock('execa')

// Mock findBin to return an absolute path
jest.mock('../src/findBin', () => (commands, paths) => {
jest.mock('../src/findBin', () => (commands) => {
const [
bin,
...otherArgs
] = commands.split(' ')

return ({
bin: `/usr/local/bin/${ bin }`,
args: otherArgs.concat(['--']).concat(paths)
args: otherArgs
})
}, { virtual: true })

Expand All @@ -44,18 +44,20 @@ const packageJSON = {
describe.only('runScript with absolute paths', () => {
beforeEach(() => {
mockFn.mockReset()
mockFn.mockImplementation(() => new Promise(() => {}))
mockFn.mockImplementation(() => Promise.resolve(true))
})

it('can pass `gitDir` as `cwd` to `execa()` when git is called via absolute path', () => {
it('can pass `gitDir` as `cwd` to `execa()` when git is called via absolute path', async () => {
const res = runScript(
['git add'],
'test.js',
['test.js'],
packageJSON,
{ gitDir: '../' }
)

expect(res[0].task()).toBeAPromise()
const taskPromise = res[0].task()
expect(taskPromise).toBeAPromise()
await taskPromise
expect(mockFn.mock.calls.length).toEqual(1)
expect(mockFn.mock.calls[0][0]).toMatch('/usr/local/bin/git')
expect(mockFn.mock.calls[0][1]).toEqual(['add', '--', 'test.js'])
Expand Down