blob: e9a4f8b75dbacdb65be7cab4f180d8e9d643f5dd [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',
// LUCI_CONTEXT is an env that exists on the bots
// We want to disable caches and run more logging there.
default: false || Boolean(process.env['LUCI_CONTEXT']),
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;
const LIT_ANALYZER_EXCLUDED_FOLDERS = [
'front_end/core',
'front_end/foundation',
'front_end/generated',
'front_end/legacy_test_runner',
'front_end/models',
'front_end/services',
'front_end/testing',
'front_end/third_party',
];
function debugLogging(messages, ...args) {
if (!flags.debug) {
return;
}
messages.push(args.map(String).join(' '));
}
async function runESLint(scriptFiles) {
if (scriptFiles.length === 0) {
return { status: true, output: '' };
}
const messages = [];
debugLogging(messages, '[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 { status: true, output: messages.join('\n') };
}
const results = await cli.lintFiles(files);
const usedDeprecatedRules = results.flatMap(
result => result.usedDeprecatedRules,
);
if (usedDeprecatedRules.length) {
messages.push('Used deprecated rules:');
for (const { ruleId, replacedBy } of usedDeprecatedRules) {
messages.push(
` 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(messages, '[lint]: EsLint suppressed the following errors:');
for (const result of results) {
debugLogging(messages, result.suppressedMessages);
}
}
if (flags.fix) {
await ESLint.outputFixes(results);
}
const formatter = await cli.loadFormatter('stylish');
const output = formatter.format(results);
if (output) {
messages.push(output);
}
return {
status: !results.find(
report => report.errorCount + report.warningCount > 0,
),
output: messages.join('\n'),
};
}
async function runStylelint(files) {
if (files.length === 0) {
return { status: true, output: '' };
}
const messages = [];
debugLogging(messages, '[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) {
messages.push(report);
}
return { status: !errored, output: messages.join('\n') };
}
/**
* 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) {
if (files.length === 0) {
return { status: true, output: '' };
}
const messages = [];
debugLogging(messages, '[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')) {
messages.push(result.output);
}
if (result.error) {
messages.push(result.error);
}
}
return { status: results.every(r => r.status), output: messages.join('\n') };
}
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) {
if (files.length === 0) {
return { status: true, output: '' };
}
const messages = [];
debugLogging(messages, '[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) {
messages.push(result.output);
}
if (result.error) {
messages.push(result.error);
}
return { status: result.status, output: messages.join('\n') };
}
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 => {
// LitAnalyzer is filtered due to high memory usage and noise in
// specific large or legacy folders.
const isInExcludedFolder = LIT_ANALYZER_EXCLUDED_FOLDERS.some(folder =>
script.includes(folder),
);
return (
// Only include front_end files, as we use Lit
// only there
script.includes('front_end') &&
// Don't lint test files as we don't use Lit
!script.endsWith('.test.ts') &&
!isInExcludedFolder
);
});
const esLintRules = scripts.filter(script =>
script.includes('scripts/eslint_rules'),
);
const results = await Promise.allSettled([
runESLint(scripts),
runLitAnalyzer(frontEndFiles),
runStylelint(styles),
runEslintRulesTypeCheck(esLintRules),
]);
let succeed = true;
for (const result of results) {
if (result.status === 'rejected') {
console.error(result.reason);
succeed = false;
continue;
}
const { status, output } = result.value;
succeed &&= status;
if (output) {
console.log(output);
}
}
return succeed;
}
run()
.then(succeed => {
process.exit(succeed ? 0 : 1);
})
.catch(err => {
console.log(`[lint]: ${err.message}`, err.stack);
process.exit(1);
});