blob: 781d63d2723ca349145baa6041ca2fa5f4ea737c [file] [log] [blame]
// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import { ESLint } from 'eslint';
import { sync } from 'globby';
import { spawn } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { extname, join, resolve, relative } from 'node:path';
import stylelint from 'stylelint';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import {
devtoolsRootPath,
litAnalyzerExecutablePath,
nodePath,
nodeModulesPath,
tsconfigJsonPath
} from '../devtools_paths.js';
const flags = yargs(hideBin(process.argv))
.option('fix', {
type: 'boolean',
default: true,
describe: 'Automatically fix, where possible, problems reported by rules.'
})
.option('force-fix', {
type: 'boolean',
default: false,
describe:
'Disables inline rule and allows auto fixers to run unconditionally.'
})
.option('debug', {
type: 'boolean',
default: false,
describe:
'Disable cache validations during debugging, useful for custom rule creation/debugging.'
})
.usage('$0 [<files...>]', 'Run the linter on the provided files', yargs => {
return yargs.positional('files', {
describe: 'File(s), glob(s), or directories',
type: 'string',
array: true,
default: [
'.',
]
});
})
.parseSync();
if (!flags.fix) {
console.log('[lint]: fix is disabled; no errors will be autofixed.');
}
if (flags.forceFix && !flags.fix) {
throw new Error('`--force-fix` need `--fix` to work as intended');
}
if (flags.debug) {
console.log('[lint]: Cache disabled, linting may take longer.');
}
const cacheLinters = !flags.debug;
function debugLogging(...args) {
if (!flags.debug) {
return;
}
console.log(...args);
}
async function runESLint(scriptFiles) {
debugLogging('[lint]: Running EsLint...');
const cli = new ESLint({
cwd: join(import.meta.dirname, '..', '..'),
fix: flags.fix,
cache: cacheLinters,
allowInlineConfig: !flags.forceFix
});
// We filter out certain files in the `eslint.config.mjs` `Ignore list` entry.
// However, ESLint produces warnings
// when you include a particular file that is ignored. This means that if you edit a file
// that is directly ignored. ESLint would report a failure.
// This was originally reported in https://github.com/eslint/eslint/issues/9977
// The suggested workaround is to use the CLIEngine to preemptively filter out these
// problematic paths.
const files = (
await Promise.all(
scriptFiles.map(async file => {
return (await cli.isPathIgnored(file)) ? null : file;
})
)
).filter(file => file !== null);
if (files.length === 0) {
// When an empty array is pass lint CWD
// This can happen only if we pass things that will
// be ignored by the above filter
// https://github.com/eslint/eslint/pull/17644
return true;
}
const results = await cli.lintFiles(files);
const usedDeprecatedRules = results.flatMap(
result => result.usedDeprecatedRules
);
if (usedDeprecatedRules.length) {
console.log('Used deprecated rules:');
for (const { ruleId, replacedBy } of usedDeprecatedRules) {
console.log(
` Rule ${ruleId} can be replaced with ${replacedBy.join(',') ?? 'none'}`
);
}
}
// Only do this for a single file as else its too noisy
// Also there is no file name we can print
if (files.length === 1) {
debugLogging('[lint]: EsLint suppressed the following errors:');
for (const result of results) {
debugLogging(result.suppressedMessages);
}
}
if (flags.fix) {
await ESLint.outputFixes(results);
}
const formatter = await cli.loadFormatter('stylish');
const output = formatter.format(results);
if (output) {
console.log(output);
}
return !results.find(report => report.errorCount + report.warningCount > 0);
}
async function runStylelint(files) {
debugLogging('[lint]: Running StyleLint...');
const { report, errored } = await stylelint.lint({
configFile: join(import.meta.dirname, '..', '..', '.stylelintrc.json'),
ignorePath: join(import.meta.dirname, '..', '..', '.stylelintignore'),
fix: flags.fix,
files,
formatter: 'string',
cache: cacheLinters,
allowEmptyInput: true
});
if (report) {
console.log(report);
}
return !errored;
}
/**
* Runs the `lit-analyzer` on the `files`.
*
* The configuration for the `lit-analyzer` is parsed from the options for
* the "ts-lit-plugin" from the toplevel `tsconfig.json` file.
*
* @param files the input files to analyze.
*/
async function runLitAnalyzer(files) {
debugLogging('[lint]: Running LitAnalyzer...');
const readLitAnalyzerConfigFromCompilerOptions = () => {
const { compilerOptions } = JSON.parse(
readFileSync(tsconfigJsonPath(), 'utf-8')
);
const { plugins } = compilerOptions;
const tsLitPluginOptions = plugins.find(
plugin => plugin.name === 'ts-lit-plugin'
);
if (tsLitPluginOptions === null) {
throw new Error(
`Failed to find ts-lit-plugin options in ${tsconfigJsonPath()}`
);
}
return tsLitPluginOptions;
};
const { rules } = readLitAnalyzerConfigFromCompilerOptions();
const getLitAnalyzerResult = async subsetFiles => {
const args = [
litAnalyzerExecutablePath(),
...Object.entries(rules).flatMap(([k, v]) => [`--rules.${k}`, v]),
...subsetFiles
];
const result = {
output: '',
error: '',
status: false
};
return await new Promise(resolve => {
const litAnalyzerProcess = spawn(nodePath(), args, {
cwd: devtoolsRootPath()
});
litAnalyzerProcess.stdout.on('data', data => {
result.output += `\n${data.toString()}`;
});
litAnalyzerProcess.stderr.on('data', data => {
result.error += `\n${data.toString()}`;
});
litAnalyzerProcess.on('error', message => {
result.error += `\n${message}`;
resolve(result);
});
litAnalyzerProcess.on('exit', code => {
result.status = code === 0;
resolve(result);
});
});
};
const getSplitFiles = filesToSplit => {
if (process.platform !== 'win32') {
return [filesToSplit];
}
/**
* @type {string[][]}
*/
const splitFiles = [[]];
let index = 0;
for (const file of filesToSplit) {
// Windows max input is 8191 so we should be conservative
if (splitFiles[index].join(' ').length + file.length < 6144) {
splitFiles[index].push(file);
} else {
index++;
splitFiles[index] = [file];
}
}
return splitFiles;
};
const results = await Promise.all(
getSplitFiles(files).map(filesBatch => {
return getLitAnalyzerResult(filesBatch);
})
);
for (const result of results) {
// Don't print if no problems are found
// Mimics the other tools
if (result.output && !result.output.includes('Found 0 problems')) {
console.log(result.output);
}
if (result.error) {
console.log(result.error);
}
}
return results.every(r => r.status);
}
const DEVTOOLS_ROOT_DIR = resolve(import.meta.dirname, '..', '..');
function shouldIgnoreFile(path) {
const resolvedPath = resolve(path);
const relativePath = relative(DEVTOOLS_ROOT_DIR, resolvedPath);
if (
relativePath.includes('third_party') ||
relativePath.includes('node_modules')
) {
return true;
}
return false;
}
async function runEslintRulesTypeCheck(_files) {
debugLogging('[lint]: Running EsLint custom rules typechecking...');
const tscPath = join(nodeModulesPath(), 'typescript', 'bin', 'tsc');
const tsConfigEslintRules = join(
devtoolsRootPath(),
'scripts',
'eslint_rules',
'tsconfig.json'
);
const args = [tscPath, '-b', tsConfigEslintRules];
/**
* @returns
*/
async function runTypeCheck() {
const result = {
output: '',
error: '',
status: false
};
return await new Promise(resolve => {
const tscProcess = spawn(nodePath(), args, {
cwd: devtoolsRootPath()
});
tscProcess.stdout.on('data', data => {
result.output += `\n${data.toString()}`;
});
tscProcess.stderr.on('data', data => {
result.error += `\n${data.toString()}`;
});
tscProcess.on('error', message => {
result.error += `\n${message}`;
resolve(result);
});
tscProcess.on('exit', code => {
result.status = code === 0;
resolve(result);
});
});
}
const result = await runTypeCheck();
if (result.output) {
console.log(result.output);
}
if (result.error) {
console.log(result.error);
}
return result.status;
}
async function run() {
const files = Array.isArray(flags.files) ? flags.files : [flags.files];
const scripts = [];
const styles = [];
for (const path of sync(files, {
expandDirectories: { extensions: ['css', 'mjs', 'js', 'ts'] },
gitignore: true
})) {
if (shouldIgnoreFile(path)) {
continue;
}
if (extname(path) === '.css') {
styles.push(path);
} else {
scripts.push(path);
}
}
const frontEndFiles = scripts.filter(script => script.includes('front_end'));
const esLintRules = scripts.filter(script =>
script.includes('scripts/eslint_rules')
);
let succeed = true;
if (scripts.length !== 0) {
succeed &&= await runESLint(scripts);
}
if (frontEndFiles.length !== 0) {
succeed &&= await runLitAnalyzer(frontEndFiles);
}
if (styles.length !== 0) {
succeed &&= await runStylelint(styles);
}
if (esLintRules.length !== 0) {
succeed &&= await runEslintRulesTypeCheck();
}
return succeed;
}
run()
.then(succeed => {
process.exit(succeed ? 0 : 1);
})
.catch(err => {
console.log(`[lint]: ${err.message}`, err.stack);
process.exit(1);
});