build: migrate from eslint to oxlint (#50691)

Consolidates the root .eslintrc.json and five nested configs (build,
script, docs, default_app, spec) into a single .oxlintrc.json at the
repo root. script/lint.js now shells out to the oxlint binary from
node_modules/.bin instead of using the ESLint Node API, and emits
GitHub Actions annotations directly via --format=github in CI
(replacing the deleted eslint-stylish problem matcher).

Oxlint has no markdown processor, so the ESLint-based lint of JS code
blocks in docs/**/*.md is replaced with an inline regex check for bare
Node.js builtin imports. This preserves the rule docs/.eslintrc.json
was originally added for in #42113; the rest of the standard ruleset on
docs code blocks was already being enforced in parallel by
lint-roller-markdown-standard.
This commit is contained in:
Samuel Attard
2026-04-06 12:05:13 -04:00
committed by GitHub
parent 903e65e048
commit 6ed3198ba8
31 changed files with 668 additions and 2301 deletions

View File

@@ -1,8 +0,0 @@
{
"plugins": [
"import"
],
"rules": {
"import/enforce-node-protocol-usage": ["error", "always"]
}
}

View File

@@ -0,0 +1,27 @@
const BLOCK_NAMES = new Set(['describe', 'it', 'context', 'test', 'specify', 'suite']);
export default {
meta: { name: 'no-only-tests' },
rules: {
'no-only-tests': {
meta: { type: 'problem' },
create (context) {
return {
MemberExpression (node) {
if (
node.property.type === 'Identifier' &&
node.property.name === 'only' &&
node.object.type === 'Identifier' &&
BLOCK_NAMES.has(node.object.name)
) {
context.report({
node: node.property,
message: `${node.object.name}.only not permitted`
});
}
}
};
}
}
}
};

View File

@@ -1,10 +1,8 @@
#!/usr/bin/env node
const { ESLint } = require('eslint');
const minimist = require('minimist');
const childProcess = require('node:child_process');
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
@@ -72,22 +70,31 @@ function spawnAndCheckExitCode (cmd, args, opts) {
}
}
async function runEslint (eslint, filenames, { fix, verbose }) {
const formatter = await eslint.loadFormatter();
let successCount = 0;
const results = await eslint.lintFiles(filenames);
for (const result of results) {
successCount += result.errorCount === 0 ? 1 : 0;
if (verbose && result.errorCount === 0 && result.warningCount === 0) {
console.log(`${result.filePath}: no errors or warnings`);
function runOxlint (filenames, { fix } = {}) {
const oxlintBin = path.join(
ELECTRON_ROOT,
'node_modules',
'.bin',
IS_WINDOWS ? 'oxlint.cmd' : 'oxlint'
);
const args = [];
if (fix) args.push('--fix');
// Emit GitHub Actions annotations directly when running in CI so errors
// surface inline on the PR without a separate problem matcher.
if (process.env.GITHUB_ACTIONS === 'true') {
args.push('--format=github');
}
for (const chunk of chunkFilenames(filenames)) {
const result = childProcess.spawnSync(oxlintBin, [...args, '--', ...chunk], {
stdio: 'inherit',
shell: IS_WINDOWS,
cwd: ELECTRON_ROOT
});
if (result.status !== 0) {
return false;
}
}
console.log(formatter.format(results));
if (fix) {
await ESLint.outputFixes(results);
}
return successCount === filenames.length;
return true;
}
function cpplint (args) {
@@ -155,15 +162,7 @@ const LINTERS = [{
ignoreRoots: ['.github/workflows/node_modules', 'spec/node_modules', 'spec/fixtures/native-addon'],
test: filename => filename.endsWith('.js') || filename.endsWith('.ts') || filename.endsWith('.mjs'),
run: async (opts, filenames) => {
const eslint = new ESLint({
// Do not use the lint cache on CI builds
cache: !process.env.CI,
cacheLocation: `node_modules/.eslintcache.${crypto.createHash('md5').update(fs.readFileSync(__filename)).digest('hex')}`,
extensions: ['.js', '.ts'],
fix: opts.fix,
resolvePluginsRelativeTo: ELECTRON_ROOT
});
const clean = await runEslint(eslint, filenames, { fix: opts.fix, verbose: opts.verbose });
const clean = runOxlint(filenames, { fix: opts.fix });
if (!clean) {
console.error('Linting had errors');
process.exit(1);
@@ -295,13 +294,56 @@ const LINTERS = [{
// Run the remaining checks only in docs
const docs = filenames.filter(filename => path.dirname(filename).split(path.sep)[0] === 'docs');
// Node.js builtin modules that should be imported with the `node:` protocol
// in docs code blocks. This mirrors what the old docs/.eslintrc.json
// enforced via `import/enforce-node-protocol-usage` (added in #42113,
// originally as `unicorn/prefer-node-protocol`).
const NODE_BUILTINS = new Set([
'assert', 'async_hooks', 'buffer', 'child_process', 'cluster', 'console',
'constants', 'crypto', 'dgram', 'diagnostics_channel', 'dns', 'domain',
'events', 'fs', 'http', 'http2', 'https', 'inspector', 'module', 'net',
'os', 'path', 'perf_hooks', 'process', 'punycode', 'querystring',
'readline', 'repl', 'stream', 'string_decoder', 'sys', 'timers', 'tls',
'trace_events', 'tty', 'url', 'util', 'v8', 'vm', 'wasi',
'worker_threads', 'zlib'
]);
const NODE_IMPORT_RE = /(?:require\s*\(\s*|from\s+|import\s*\(\s*)['"]([^'"/]+)(?:\/[^'"]*)?['"]/g;
const BREAKING_CHANGES_MD = path.join('docs', 'breaking-changes.md');
// Strip line and block comments from a code snippet so the import check
// does not flag bare-specifier examples that appear inside `// ...` or
// `/* ... */` explanations. This is a conservative textual strip — it is
// not a full JS parser, but it is good enough for docs code blocks and
// matches the behavior of the AST-based rule it replaced.
const stripComments = (source) => source
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '))
.replace(/(^|[^:])\/\/[^\n]*/g, (_m, prefix) => prefix);
for (const filename of docs) {
const contents = fs.readFileSync(filename, 'utf8');
const codeBlocks = await getCodeBlocks(contents);
const skipNodeProtocolCheck = path.normalize(filename).endsWith(BREAKING_CHANGES_MD);
for (const codeBlock of codeBlocks) {
const line = codeBlock.position.start.line;
// Check for bare Node.js builtin imports in JS/TS/fiddle code blocks.
if (!skipNodeProtocolCheck && codeBlock.lang && ['js', 'ts', 'javascript', 'typescript', 'fiddle'].includes(codeBlock.lang.toLowerCase())) {
const blockLines = stripComments(codeBlock.value).split('\n');
for (let i = 0; i < blockLines.length; i++) {
NODE_IMPORT_RE.lastIndex = 0;
let match;
while ((match = NODE_IMPORT_RE.exec(blockLines[i])) !== null) {
const mod = match[1];
if (NODE_BUILTINS.has(mod)) {
console.log(`${filename}:${line + 1 + i} Use 'node:${mod}' instead of bare '${mod}' in code blocks`);
errors = true;
}
}
}
}
if (codeBlock.lang) {
// Enforce all lowercase language identifiers
if (codeBlock.lang.toLowerCase() !== codeBlock.lang) {
@@ -368,26 +410,6 @@ const LINTERS = [{
}
}
const eslint = new ESLint({
// Do not use the lint cache on CI builds
cache: !process.env.CI,
cacheLocation: `node_modules/.eslintcache.${crypto.createHash('md5').update(fs.readFileSync(__filename)).digest('hex')}`,
fix: opts.fix,
overrideConfigFile: path.join(ELECTRON_ROOT, 'docs', '.eslintrc.json'),
resolvePluginsRelativeTo: ELECTRON_ROOT
});
const clean = await runEslint(
eslint,
docs.filter(
// TODO(dsanders11): Once we move to newer ESLint and the flat config,
// switch to using `ignorePatterns` and `warnIgnore: false` instead of
// explicitly filtering out this file that we don't want to lint
(filename) => !filename.endsWith('docs/breaking-changes.md')
),
{ fix: opts.fix, verbose: opts.verbose }
);
errors ||= !clean;
if (errors) {
process.exit(1);
}