import { Options as PrettierOptions } from 'prettier'; import * as fs from 'fs'; import * as glob from 'glob'; import * as optimist from 'optimist'; import * as path from 'path'; import * as tslint from 'tslint'; import { promisify } from 'util'; // TODO: Switch to using fs.promises when dropping node 8 support const realpathAsync = promisify(fs.realpath); const readFileAsync = promisify(fs.readFile); const accessAsync = promisify(fs.access); const statAsync = promisify(fs.stat); const writeFileAsync = promisify(fs.writeFile); const existsAsync = async (filename: string) => { try { await accessAsync(filename); return true; } catch { return false; } }; const globAsync = promisify(glob); interface ResinLintConfig { configPath: string; configFileName: string; extensions: string[]; lang: 'coffeescript' | 'typescript'; prettierCheck?: boolean; testsCheck?: boolean; } const configurations: { [key: string]: ResinLintConfig } = { coffeescript: { configPath: path.join(__dirname, '../config/coffeelint.json'), configFileName: 'coffeelint.json', extensions: ['coffee'], lang: 'coffeescript', }, typescript: { configPath: path.join(__dirname, '../config/tslint.json'), configFileName: 'tslint.json', extensions: ['ts', 'tsx'], lang: 'typescript', }, typescriptPrettier: { configPath: path.join(__dirname, '../config/tslint-prettier.json'), configFileName: 'tslint.json', extensions: ['ts', 'tsx'], lang: 'typescript', }, }; const prettierConfigPath = path.join(__dirname, '../config/.prettierrc'); /** * The linter expects the path to actual source files, for example: * src/ * test/ * but depcheck expects the root of a project directory (where the * package.json is). This function takes a path and propagates upwards * until it contains a package.json */ const getPackageJsonDir = async (dir: string): Promise => { const name = await findFile('package.json', dir); if (name === null) { throw new Error('Could not find package.json!'); } return path.dirname(name); }; const read = async (filepath: string): Promise => { const realPath = await realpathAsync(filepath); return readFileAsync(realPath, 'utf8'); }; const findFile = async (name: string, dir?: string): Promise => { dir = dir || process.cwd(); const filename = path.join(dir, name); if (await existsAsync(filename)) { return filename; } const parent = path.dirname(dir); if (dir === parent) { return null; } return findFile(name, parent); }; const parseJSON = async (file: string): Promise<{}> => { try { return JSON.parse(await readFileAsync(file, 'utf8')); } catch (err) { console.error(`Could not parse ${file}`); throw err; } }; const findFiles = async ( extensions: string[], paths: string[] = [], ): Promise => { const files: string[] = []; await Promise.all( paths.map(async p => { if ((await statAsync(p)).isDirectory()) { files.push( ...(await globAsync(`${p}/**/*.@(${extensions.join('|')})`)), ); } else { files.push(p); } }), ); return files.map(p => path.join(p)); }; const lintCoffeeFiles = async ( files: string[], config: {}, ): Promise => { const coffeelint: any = require('coffeelint'); const errorReport = new coffeelint.getErrorReport(); await Promise.all( files.map(async file => { const source = await read(file); errorReport.lint(file, source, config); }), ); const reporter: any = require('coffeelint/lib/reporters/default'); const report = new reporter(errorReport, { colorize: process.stdout.isTTY, quiet: false, }); report.publish(); return errorReport.getExitCode(); }; const lintTsFiles = async function( files: string[], config: {}, prettierConfig: PrettierOptions | undefined, autoFix: boolean, ): Promise { const prettier = prettierConfig ? await import('prettier') : undefined; const linter = new tslint.Linter({ fix: autoFix, formatter: 'stylish', }); const exitCodes = await Promise.all( files.map(async file => { let source = await read(file); linter.lint( file, source, config as tslint.Configuration.IConfigurationFile, ); if (prettier) { if (autoFix) { const newSource = prettier.format(source, prettierConfig); if (source !== newSource) { source = newSource; await writeFileAsync(file, source); } } else { const isPrettified = prettier.check(source, prettierConfig); if (!isPrettified) { console.log( `Error: File ${file} hasn't been formatted with prettier`, ); return 1; } } } return 0; }), ); const failureCode = exitCodes.find(exitCode => exitCode !== 0); if (failureCode) { return failureCode; } const errorReport = linter.getResult(); // Print the linter results console.log(linter.getResult().output); return errorReport.errorCount === 0 ? 0 : 1; }; const lintMochaTestFiles = async function(files: string[]): Promise { const { lintMochaTests } = await import('./mocha-tests-lint'); const res = await lintMochaTests(files); if (res.isError) { console.error('Mocha tests check FAILED!'); console.error(res.message); return 1; } return 0; }; const runLint = async function( resinLintConfig: ResinLintConfig, paths: string[], config: {}, autoFix: boolean, ) { let linterExitCode: number | undefined; const scripts = await findFiles(resinLintConfig.extensions, paths); if (resinLintConfig.lang === 'typescript') { let prettierConfig: PrettierOptions | undefined; if (resinLintConfig.prettierCheck) { prettierConfig = (await parseJSON(prettierConfigPath)) as PrettierOptions; prettierConfig.parser = 'typescript'; } linterExitCode = await lintTsFiles( scripts, config, prettierConfig, autoFix, ); } if (resinLintConfig.lang === 'coffeescript') { linterExitCode = await lintCoffeeFiles(scripts, config); } if (resinLintConfig.testsCheck) { const testsExitCode = await lintMochaTestFiles(scripts); if (linterExitCode === 0) { linterExitCode = testsExitCode; } } process.on('exit', () => process.exit(linterExitCode)); }; export const lint = async (passedParams: any) => { const options = optimist(passedParams) .usage('Usage: resin-lint [options] [...]') .describe( 'f', 'Specify a linting config file to extend and override resin-lint rules', ) .describe('p', 'Print default resin-lint linting rules') .describe( 'i', 'Ignore linting config files in project directory and its parents', ) .boolean('typescript', 'Lint typescript files instead of coffeescript') .boolean('fix', 'Attempt to automatically fix lint errors') .boolean('no-prettier', 'Disables the prettier code format checks') .boolean( 'tests', 'Treat input files as test sources to perform extra relevant checks', ) .boolean('u', 'Run unused import check'); if (options.argv._.length < 1 && !options.argv.p) { options.showHelp(); process.exit(1); } if (options.argv.u) { const depcheck = await import('depcheck'); await Promise.all( options.argv._.map(async (dir: string) => { dir = await getPackageJsonDir(dir); const { dependencies } = await depcheck(path.resolve('./', dir), { ignoreMatches: [ '@types/*', // ignore typescript type declarations 'supervisor', // isn't used directly from source 'coffee-script', // Gives false positives 'coffeescript', // An alias 'colors', // Generally imported via colors/safe, which doesn't trigger depcheck 'coffeescope2', ], }); if (dependencies.length > 0) { console.log(`${dependencies.length} unused dependencies:`); for (const dep of dependencies) { console.log(`\t${dep}`); } process.exit(1); } console.log('No unused dependencies!'); console.log(); }), ); } let configOverridePath; // optimist converts all --no-xyz args to a argv.xyz === false const prettierCheck = options.argv.prettier !== false; const testsCheck = options.argv.tests === true; const typescriptCheck = options.argv.typescript; const autoFix = options.argv.fix === true; const resinLintConfiguration = typescriptCheck ? prettierCheck ? configurations.typescriptPrettier : configurations.typescript : configurations.coffeescript; if (options.argv.p) { console.log(await readFileAsync(resinLintConfiguration.configPath, 'utf8')); process.exit(0); } // TSLint config needs to be loaded with `loadConfigurationFromPath` // Coffeelint needs to be loaded as a plain file let config: {} = typescriptCheck ? tslint.Configuration.loadConfigurationFromPath( resinLintConfiguration.configPath, ) : await parseJSON(resinLintConfiguration.configPath); if (options.argv.f) { configOverridePath = await realpathAsync(options.argv.f); } if (!options.argv.i && !configOverridePath) { configOverridePath = await findFile(resinLintConfiguration.configFileName); } if (configOverridePath) { // Extend/override default config if (typescriptCheck) { const configOverride = tslint.Configuration.loadConfigurationFromPath( configOverridePath, ); config = tslint.Configuration.extendConfigurationFile( config as tslint.Configuration.IConfigurationFile, configOverride, ); } else { const configOverride = await parseJSON(configOverridePath); const { merge } = await import('lodash'); config = merge(config, configOverride); } } const paths = options.argv._; resinLintConfiguration.prettierCheck = prettierCheck; resinLintConfiguration.testsCheck = testsCheck; await runLint(resinLintConfiguration, paths, config, autoFix); };