diff --git a/.github/workflows/release_apps.yml b/.github/workflows/release_apps.yml index 29046fd5527ca..e1010939bc984 100644 --- a/.github/workflows/release_apps.yml +++ b/.github/workflows/release_apps.yml @@ -277,6 +277,7 @@ jobs: env: package_path: npm/oxlint plugins_package_path: npm/oxlint-plugins + plugin_eslint_package_path: npm/oxlint-plugin-eslint npm_dir: npm/oxlint-release PUBLISH_FLAGS: "--provenance --access public --no-git-checks" steps: @@ -306,12 +307,17 @@ jobs: run: | cp apps/oxlint/dist-pkg-plugins/* ${plugins_package_path}/ + - name: Copy dist files to oxlint-plugin-eslint npm package + run: | + cp -r apps/oxlint/dist-pkg-plugin-eslint/. ${plugin_eslint_package_path}/ + - run: npm install -g npm@latest # For trusted publishing support - name: Check Publish run: | node .github/scripts/check-npm-packages.js "${npm_dir}/*" "${package_path}" node .github/scripts/check-npm-packages.js "${plugins_package_path}" + node .github/scripts/check-npm-packages.js "${plugin_eslint_package_path}" - name: Trusted Publish run: | @@ -322,6 +328,8 @@ jobs: pnpm publish ${package_path}/ ${PUBLISH_FLAGS} # Publish `@oxlint/plugins` package pnpm publish ${plugins_package_path}/ ${PUBLISH_FLAGS} + # Publish `oxlint-plugin-eslint` package + pnpm publish ${plugin_eslint_package_path}/ ${PUBLISH_FLAGS} build-oxfmt: needs: check diff --git a/apps/oxlint/.gitignore b/apps/oxlint/.gitignore index 8b33a8b91eb50..e1e1120765b11 100644 --- a/apps/oxlint/.gitignore +++ b/apps/oxlint/.gitignore @@ -1,4 +1,6 @@ /node_modules/ /dist/ /dist-pkg-plugins/ +/dist-pkg-plugin-eslint/ +/src-js/generated/plugin-eslint/ *.node diff --git a/apps/oxlint/package.json b/apps/oxlint/package.json index f2ce7925384e7..1930de83d9b57 100644 --- a/apps/oxlint/package.json +++ b/apps/oxlint/package.json @@ -46,6 +46,7 @@ "@types/json-schema": "^7.0.15", "@types/json-stable-stringify-without-jsonify": "^1.0.2", "@types/node": "catalog:", + "@types/serialize-javascript": "^5.0.4", "@typescript-eslint/parser": "^8.54.0", "@typescript-eslint/scope-manager": "^8.54.0", "ajv": "6.14.0", @@ -58,6 +59,7 @@ "json-stable-stringify-without-jsonify": "^1.0.1", "oxc-parser": "^0.117.0", "rolldown": "catalog:", + "serialize-javascript": "^7.0.4", "tsdown": "catalog:", "tsx": "^4.21.0", "type-fest": "^5.2.0", diff --git a/apps/oxlint/scripts/build.ts b/apps/oxlint/scripts/build.ts index d47b5afa9c627..2f451b4368ded 100755 --- a/apps/oxlint/scripts/build.ts +++ b/apps/oxlint/scripts/build.ts @@ -3,16 +3,26 @@ import { execSync } from "node:child_process"; import { copyFileSync, readdirSync, rmSync } from "node:fs"; import { join } from "node:path"; +import generatePluginEslint from "./generate-plugin-eslint.ts"; const oxlintDirPath = join(import.meta.dirname, ".."), srcDirPath = join(oxlintDirPath, "src-js"), distDirPath = join(oxlintDirPath, "dist"), - distPkgPluginsDirPath = join(oxlintDirPath, "dist-pkg-plugins"); + distPkgPluginsDirPath = join(oxlintDirPath, "dist-pkg-plugins"), + distPkgPluginEslintDirPath = join(oxlintDirPath, "dist-pkg-plugin-eslint"); // Delete `dist-pkg-plugins` directory console.log("Deleting `dist-pkg-plugins` directory..."); rmSync(distPkgPluginsDirPath, { recursive: true, force: true }); +// Delete `dist-pkg-plugin-eslint` directory +console.log("Deleting `dist-pkg-plugin-eslint` directory..."); +rmSync(distPkgPluginEslintDirPath, { recursive: true, force: true }); + +// Generate plugin-eslint files +console.log("Generating oxlint-plugin-eslint files..."); +generatePluginEslint(); + // Build with tsdown console.log("Building with tsdown..."); execSync("pnpm tsdown", { stdio: "inherit", cwd: oxlintDirPath }); diff --git a/apps/oxlint/scripts/generate-plugin-eslint.ts b/apps/oxlint/scripts/generate-plugin-eslint.ts new file mode 100644 index 0000000000000..db8cfc8905eef --- /dev/null +++ b/apps/oxlint/scripts/generate-plugin-eslint.ts @@ -0,0 +1,170 @@ +/** + * Generates the `oxlint-plugin-eslint` package source files. + * + * This script produces: + * + * 1. `rules/.cjs` - One file for each ESLint core rule, that re-exports the rule's `create` function. + * 2. `index.ts` - Exports all rules as a `Record`. + * This is the `rules` property of the `oxlint-plugin-eslint` plugin. + * 3. `rule_names.ts` - Exports a list of all rule names, which is used in TSDown config. + * + * `index.ts` uses a split eager/lazy strategy so that `registerPlugin` can read each rule's `meta` + * without loading the rule module itself: + * + * - `meta` is serialized and inlined at build time. + * `registerPlugin` needs it at plugin registration time (for `fixable`, `hasSuggestions`, `schema`, + * `defaultOptions`, `messages`), so it must be available immediately without requiring the rule module. + * + * - `create` is deferred via a cached `require` call. + * The rule module is only loaded the first time `create` is called (i.e. when the rule actually runs at lint time). + * A top-level variable per rule caches the loaded function so subsequent calls skip the `require` call. + * + * Build-time validations: + * - Each rule object must only have `meta` and `create` properties. + * - `meta` values are walked to ensure they contain no functions + * (which would be serialized as executable code by `serialize-javascript`). + */ + +import { readdirSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join as pathJoin, basename, relative as pathRelative } from "node:path"; +import { createRequire } from "node:module"; +import { execFileSync } from "node:child_process"; +import serialize from "serialize-javascript"; + +import type { CreateRule } from "../src-js/plugins/load.ts"; +import type { RuleMeta } from "../src-js/plugins/rule_meta.ts"; + +const require = createRequire(import.meta.url); + +const oxlintDirPath = pathJoin(import.meta.dirname, ".."); +const rootDirPath = pathJoin(oxlintDirPath, "../.."); +const eslintRulesDir = pathJoin(require.resolve("eslint/package.json"), "../lib/rules"); +const generatedDirPath = pathJoin(oxlintDirPath, "src-js/generated/plugin-eslint"); +const generatedRulesDirPath = pathJoin(generatedDirPath, "rules"); + +export default function generatePluginEslint(): void { + // Get all ESLint rule names (exclude `index.js` which is the registry, not a rule) + const ruleNames = readdirSync(eslintRulesDir) + .filter((filename) => filename.endsWith(".js") && filename !== "index.js") + .map((filename) => basename(filename, ".js")) + .sort(); + + // oxlint-disable-next-line no-console + console.log(`Found ${ruleNames.length} ESLint rules`); + + // Wipe and recreate generated directories + rmSync(generatedDirPath, { recursive: true, force: true }); + mkdirSync(generatedRulesDirPath, { recursive: true }); + + // Generate a CJS wrapper file for each rule + for (const ruleName of ruleNames) { + const relPath = pathRelative(generatedRulesDirPath, pathJoin(eslintRulesDir, `${ruleName}.js`)); + const content = `module.exports = require(${JSON.stringify(relPath)}).create;\n`; + writeFileSync(pathJoin(generatedRulesDirPath, `${ruleName}.cjs`), content); + } + + // Generate the plugin rules index. + // `meta` is inlined so it's available at registration time without loading the rule module. + // `create` is deferred via a cached `require` so the rule module is only loaded on first use. + const indexLines = [ + ` + import { createRequire } from "node:module"; + + import type { CreateRule } from "../../plugins/load.ts"; + + type CreateFn = CreateRule["create"]; + + var require = createRequire(import.meta.url); + `, + ]; + + // Generate a `let` declaration for each rule's cached `create` function. + // These are initially `null` and populated on first call. + for (let i = 0; i < ruleNames.length; i++) { + indexLines.push(`var create${i}: CreateFn | null = null;`); + } + + indexLines.push("", "export default {"); + + for (let i = 0; i < ruleNames.length; i++) { + const ruleName = ruleNames[i]; + const rulePath = pathJoin(eslintRulesDir, `${ruleName}.js`); + const rule: CreateRule = require(rulePath); + + // Validate that the rule only has expected top-level properties. + // If ESLint adds new properties in a future version, we want to find out at build time. + const unexpectedKeys = Object.keys(rule).filter((key) => key !== "meta" && key !== "create"); + if (unexpectedKeys.length > 0) { + throw new Error( + `Unexpected properties on rule \`${ruleName}\`: ${unexpectedKeys.join(", ")}. ` + + "Expected only `meta` and `create`.", + ); + } + + // Reduce `meta` to only the properties Oxlint uses, with consistent shape and property order. + // We discard e.g. `deprecated` and `docs` properties. This reduces code size. + // Default values match what `registerPlugin` assumes when a property is absent. + const { meta } = rule; + const reducedMeta: RuleMeta = { + messages: meta?.messages ?? undefined, + fixable: meta?.fixable ?? null, + hasSuggestions: meta?.hasSuggestions ?? false, + schema: meta?.schema ?? undefined, + defaultOptions: meta?.defaultOptions ?? undefined, + }; + + // Check for function values in `reducedMeta`, which would be unexpected and likely a bug. + // `serialize-javascript` would serialize them as executable code, so catch this at build time. + assertNoFunctions(reducedMeta, `eslint/lib/rules/${ruleName}.js`, "meta"); + + const metaCode = serialize(reducedMeta, { unsafe: true }); + + indexLines.push(` + ${JSON.stringify(ruleName)}: { + meta: ${metaCode}, + create(context) { + if (create${i} === null) create${i} = require("./rules/${ruleName}.cjs") as CreateFn; + return create${i}(context); + }, + }, + `); + } + indexLines.push("} satisfies Record;\n"); + + const indexFilePath = pathJoin(generatedDirPath, "index.ts"); + writeFileSync(indexFilePath, indexLines.join("\n")); + + // Format generated index file with oxfmt to clean up unnecessary quotes around property names. + // This isn't necessary, as it gets minified and bundled anyway, but it makes generated code easier to read + // when debugging. + execFileSync("pnpm", ["exec", "oxfmt", "--write", indexFilePath], { cwd: rootDirPath }); + + // Generate the rule_names.ts file for use in tsdown config + const ruleNamesCode = [ + "export default [", + ...ruleNames.map((name) => ` ${JSON.stringify(name)},`), + "] as const;\n", + ].join("\n"); + + writeFileSync(pathJoin(generatedDirPath, "rule_names.ts"), ruleNamesCode); + + // oxlint-disable-next-line no-console + console.log("Generated plugin-eslint files."); +} + +/** + * Walk an object tree and throw if any function values are found. + */ +function assertNoFunctions(value: unknown, rulePath: string, path: string): void { + if (typeof value === "function") { + throw new Error( + `Unexpected function value in \`${path}\` of rule \`${rulePath}\`. ` + + "Rule meta objects must be static data.", + ); + } + if (typeof value === "object" && value !== null) { + for (const [key, child] of Object.entries(value)) { + assertNoFunctions(child, rulePath, `${path}.${key}`); + } + } +} diff --git a/apps/oxlint/src-js/plugin-eslint/index.ts b/apps/oxlint/src-js/plugin-eslint/index.ts new file mode 100644 index 0000000000000..30ac5b728380d --- /dev/null +++ b/apps/oxlint/src-js/plugin-eslint/index.ts @@ -0,0 +1,10 @@ +// oxlint-disable-next-line typescript/ban-ts-comment +// @ts-ignore - file is generated and not checked in to git +import rules from "../generated/plugin-eslint/index.ts"; + +export default { + meta: { + name: "eslint-js", + }, + rules, +}; diff --git a/apps/oxlint/test/fixtures/plugin_eslint/.oxlintrc.json b/apps/oxlint/test/fixtures/plugin_eslint/.oxlintrc.json new file mode 100644 index 0000000000000..0a690e7f6a4b6 --- /dev/null +++ b/apps/oxlint/test/fixtures/plugin_eslint/.oxlintrc.json @@ -0,0 +1,16 @@ +{ + "categories": { + "correctness": "off" + }, + "jsPlugins": ["../../../dist-pkg-plugin-eslint/index.js"], + "rules": { + "eslint-js/array-bracket-newline": ["error", "consistent"], + "eslint-js/no-restricted-syntax": [ + "error", + { + "selector": "ThrowStatement > CallExpression[callee.name=/Error$/]", + "message": "Use `new` keyword when throwing an `Error`." + } + ] + } +} diff --git a/apps/oxlint/test/fixtures/plugin_eslint/files/index.js b/apps/oxlint/test/fixtures/plugin_eslint/files/index.js new file mode 100644 index 0000000000000..3609baa5199f3 --- /dev/null +++ b/apps/oxlint/test/fixtures/plugin_eslint/files/index.js @@ -0,0 +1,6 @@ +// Violation: array-bracket-newline (opening has newline, closing does not) +const a = [ + 1, 2, 3]; + +// Violation: no-restricted-syntax (throw Error without `new`) +throw TypeError("bad"); diff --git a/apps/oxlint/test/fixtures/plugin_eslint/output.snap.md b/apps/oxlint/test/fixtures/plugin_eslint/output.snap.md new file mode 100644 index 0000000000000..bfc113c2baf3f --- /dev/null +++ b/apps/oxlint/test/fixtures/plugin_eslint/output.snap.md @@ -0,0 +1,27 @@ +# Exit code +1 + +# stdout +``` + x eslint-js(array-bracket-newline): A linebreak is required before ']'. + ,-[files/index.js:3:10] + 2 | const a = [ + 3 | 1, 2, 3]; + : ^ + 4 | + `---- + + x eslint-js(no-restricted-syntax): Use `new` keyword when throwing an `Error`. + ,-[files/index.js:6:7] + 5 | // Violation: no-restricted-syntax (throw Error without `new`) + 6 | throw TypeError("bad"); + : ^^^^^^^^^^^^^^^^ + `---- + +Found 0 warnings and 2 errors. +Finished in Xms on 1 file with 2 rules using X threads. +``` + +# stderr +``` +``` diff --git a/apps/oxlint/tsdown.config.ts b/apps/oxlint/tsdown.config.ts index f842aa3da5360..6048fe385fc0f 100644 --- a/apps/oxlint/tsdown.config.ts +++ b/apps/oxlint/tsdown.config.ts @@ -2,6 +2,9 @@ import fs from "node:fs"; import { join as pathJoin, relative as pathRelative, dirname } from "node:path"; import { defineConfig } from "tsdown"; import { parseSync, Visitor } from "oxc-parser"; +// oxlint-disable-next-line typescript/ban-ts-comment +// @ts-ignore - file is generated and not checked in to git +import ruleNames from "./src-js/generated/plugin-eslint/rule_names.ts"; import type { Plugin } from "rolldown"; @@ -65,6 +68,25 @@ const pluginsPkgConfig = defineConfig({ define: definedGlobals, }); +// Base config for `oxlint-plugin-eslint` package +const pluginEslintPkgConfig = defineConfig({ + ...commonConfig, + outDir: "dist-pkg-plugin-eslint", + minify: minifyConfig, + // `build.ts` deletes the directory before TSDown runs. + // This allows generating the ESM and CommonJS builds in the same directory. + clean: false, + dts: false, +}); + +// Build entries for `oxlint-plugin-eslint` rule files. +// Each rule is a separate CJS file, lazy-loaded on demand. +const pluginEslintRulesEntries: Record = {}; +for (const ruleName of ruleNames) { + pluginEslintRulesEntries[`rules/${ruleName}`] = + `src-js/generated/plugin-eslint/rules/${ruleName}.cjs`; +} + // Plugins. // Only remove debug assertions in release build. const plugins = [createReplaceGlobalsPlugin()]; @@ -108,6 +130,18 @@ export default defineConfig([ format: "commonjs", dts: false, }, + + // `oxlint-plugin-eslint` package + { + ...pluginEslintPkgConfig, + entry: { index: "src-js/plugin-eslint/index.ts" }, + format: "esm", + }, + { + ...pluginEslintPkgConfig, + entry: pluginEslintRulesEntries, + format: "commonjs", + }, ]); /** diff --git a/npm/oxlint-plugin-eslint/CHANGELOG.md b/npm/oxlint-plugin-eslint/CHANGELOG.md new file mode 100644 index 0000000000000..e30b69168493c --- /dev/null +++ b/npm/oxlint-plugin-eslint/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +All notable changes to this package will be documented in this file. diff --git a/npm/oxlint-plugin-eslint/README.md b/npm/oxlint-plugin-eslint/README.md new file mode 100644 index 0000000000000..e8553d24ff4b7 --- /dev/null +++ b/npm/oxlint-plugin-eslint/README.md @@ -0,0 +1,36 @@ +# oxlint-plugin-eslint + +ESLint's built-in rules as an Oxlint plugin. + +This package exports all of ESLint's built-in rules as a JS plugin that Oxlint users can use. + +Allows using ESLint rules that Oxlint doesn't implement natively yet. + +More details in [Oxlint docs](https://oxc.rs/docs/guide/usage/linter/js-plugins). + +## Usage + +Install the package: + +```sh +npm install --save-dev oxlint-plugin-eslint +``` + +Add to your Oxlint config: + +```json +{ + "jsPlugins": ["oxlint-plugin-eslint"], + "rules": { + "eslint-js/no-restricted-syntax": [ + "error", + { + "selector": "ThrowStatement > CallExpression[callee.name=/Error$/]", + "message": "Use `new` keyword when throwing an `Error`." + } + ] + } +} +``` + +All rules are prefixed with `eslint-js/`, to distinguish from Oxlint's Rust implementation of ESLint rules. diff --git a/npm/oxlint-plugin-eslint/package.json b/npm/oxlint-plugin-eslint/package.json new file mode 100644 index 0000000000000..b4bbc9370b815 --- /dev/null +++ b/npm/oxlint-plugin-eslint/package.json @@ -0,0 +1,33 @@ +{ + "name": "oxlint-plugin-eslint", + "version": "1.52.0", + "description": "ESLint's built-in rules as an Oxlint plugin", + "keywords": [ + "eslint", + "linter", + "oxlint", + "plugin" + ], + "homepage": "https://oxc.rs/docs/guide/usage/linter/js-plugins", + "bugs": "https://github.com/oxc-project/oxc/issues", + "license": "MIT", + "author": "Boshen and oxc contributors", + "repository": { + "type": "git", + "url": "git+https://github.com/oxc-project/oxc.git", + "directory": "npm/oxlint-plugin-eslint" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "files": [ + "index.js", + "rules", + "README.md" + ], + "type": "module", + "main": "index.js", + "engines": { + "node": "^20.19.0 || >=22.12.0" + } +} diff --git a/oxc_release.toml b/oxc_release.toml index b2fc598852416..7ecfa2259b077 100644 --- a/oxc_release.toml +++ b/oxc_release.toml @@ -21,6 +21,7 @@ versioned_files = [ "apps/oxlint/package.json", "crates/oxc_linter/Cargo.toml", "npm/oxlint/package.json", + "npm/oxlint-plugin-eslint/package.json", "npm/oxlint-plugins/package.json", ] diff --git a/oxfmtrc.jsonc b/oxfmtrc.jsonc index ec7ab6bbe8604..df032436d9a8e 100644 --- a/oxfmtrc.jsonc +++ b/oxfmtrc.jsonc @@ -7,6 +7,7 @@ "!apps/oxlint/test/fixtures/**", "apps/oxlint/test/fixtures/bom/files/**", "apps/oxlint/test/fixtures/fixes/files/**", + "apps/oxlint/test/fixtures/plugin_eslint/files/**", "apps/oxlint/test/fixtures/suggestions/files/**", "apps/oxlint/test/fixtures/sourceCode_token_methods/files/**", "apps/oxlint/test/fixtures/tokens/files/**", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 12b00f2419b8d..e45ddb935b030 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,6 +132,9 @@ importers: '@types/node': specifier: 'catalog:' version: 24.1.0 + '@types/serialize-javascript': + specifier: ^5.0.4 + version: 5.0.4 '@typescript-eslint/parser': specifier: ^8.54.0 version: 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) @@ -168,6 +171,9 @@ importers: rolldown: specifier: 'catalog:' version: 1.0.0-rc.8 + serialize-javascript: + specifier: ^7.0.4 + version: 7.0.4 tsdown: specifier: 'catalog:' version: 0.21.1(@arethetypeswrong/core@0.18.2)(publint@0.3.18)(typescript@5.9.3) @@ -287,6 +293,8 @@ importers: specifier: '>=0.15.0' version: 0.16.0 + npm/oxlint-plugin-eslint: {} + npm/oxlint-plugins: {} npm/runtime: {} @@ -2643,6 +2651,9 @@ packages: '@types/node@25.0.2': resolution: {integrity: sha512-gWEkeiyYE4vqjON/+Obqcoeffmk0NF15WSBwSs7zwVA2bAbTaE0SJ7P0WNGoJn8uE7fiaV5a7dKYIJriEqOrmA==} + '@types/serialize-javascript@5.0.4': + resolution: {integrity: sha512-Z2R7UKFuNWCP8eoa2o9e5rkD3hmWxx/1L0CYz0k2BZzGh0PhEVMp9kfGiqEml/0IglwNERXZ2hwNzIrSz/KHTA==} + '@types/superagent@8.1.9': resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} @@ -4168,6 +4179,10 @@ packages: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} + serialize-javascript@7.0.4: + resolution: {integrity: sha512-DuGdB+Po43Q5Jxwpzt1lhyFSYKryqoNjQSA9M92tyw0lyHIOur+XCalOUe0KTJpyqzT8+fQ5A0Jf7vCx/NKmIg==} + engines: {node: '>=20.0.0'} + seroval-plugins@1.3.3: resolution: {integrity: sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w==} engines: {node: '>=10'} @@ -6620,6 +6635,8 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/serialize-javascript@5.0.4': {} + '@types/superagent@8.1.9': dependencies: '@types/cookiejar': 2.1.5 @@ -8315,6 +8332,8 @@ snapshots: transitivePeerDependencies: - supports-color + serialize-javascript@7.0.4: {} + seroval-plugins@1.3.3(seroval@1.3.2): dependencies: seroval: 1.3.2