Skip to content

Commit fe3b32e

Browse files
committed
feat(linter/plugins): add oxlint-plugin-eslint package (#20009)
Closes #19746. Add `oxlint-plugin-eslint` plugin which exports all ESLint's built-in rules as a JS plugin which can be used with Oxlint. The code for rules is lazy-loaded on first use, so just using the plugin doesn't load the code for all the rules, only the ones which the user actually uses. However, the `meta` property of each rule is provided eagerly, so that `registerPlugin` in Oxlint can load the plugin without triggering the getters which lazy-load rule code. All ESLint's rule code is bundled into this plugin package, so the package has no dependencies. This has a few advantages: 1. We control the version of ESLint which the rules are taken from. 2. Avoids "works on my machine" unreproducible bug reports due to differing ESLint versions. 3. Avoids relying on ESLint's `eslint/use-at-your-own-risk` export, which could change without warning. At present, the plugin contains *all* ESLint's rules, including ones which are natively implemented in Oxlint. We might choose to scale it back to include only the rules which Oxlint doesn't natively implement. But then we need to consider what happens when we implement more rules in future: Do they disappear from this package without warning? Would that be considered a breaking change? If it would, how long do we keep them in this package? I propose that in the interests of getting this useful feature released without having to decide all these issues, we just go with it as is for now. JS plugins are not yet stable, so at present we can still make changes later on if we need to, without breaking any promises.
1 parent 1fef171 commit fe3b32e

File tree

16 files changed

+379
-1
lines changed

16 files changed

+379
-1
lines changed

.github/workflows/release_apps.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,7 @@ jobs:
277277
env:
278278
package_path: npm/oxlint
279279
plugins_package_path: npm/oxlint-plugins
280+
plugin_eslint_package_path: npm/oxlint-plugin-eslint
280281
npm_dir: npm/oxlint-release
281282
PUBLISH_FLAGS: "--provenance --access public --no-git-checks"
282283
steps:
@@ -306,12 +307,17 @@ jobs:
306307
run: |
307308
cp apps/oxlint/dist-pkg-plugins/* ${plugins_package_path}/
308309
310+
- name: Copy dist files to oxlint-plugin-eslint npm package
311+
run: |
312+
cp -r apps/oxlint/dist-pkg-plugin-eslint/. ${plugin_eslint_package_path}/
313+
309314
- run: npm install -g npm@latest # For trusted publishing support
310315

311316
- name: Check Publish
312317
run: |
313318
node .github/scripts/check-npm-packages.js "${npm_dir}/*" "${package_path}"
314319
node .github/scripts/check-npm-packages.js "${plugins_package_path}"
320+
node .github/scripts/check-npm-packages.js "${plugin_eslint_package_path}"
315321
316322
- name: Trusted Publish
317323
run: |
@@ -322,6 +328,8 @@ jobs:
322328
pnpm publish ${package_path}/ ${PUBLISH_FLAGS}
323329
# Publish `@oxlint/plugins` package
324330
pnpm publish ${plugins_package_path}/ ${PUBLISH_FLAGS}
331+
# Publish `oxlint-plugin-eslint` package
332+
pnpm publish ${plugin_eslint_package_path}/ ${PUBLISH_FLAGS}
325333
326334
build-oxfmt:
327335
needs: check

apps/oxlint/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
/node_modules/
22
/dist/
33
/dist-pkg-plugins/
4+
/dist-pkg-plugin-eslint/
5+
/src-js/generated/plugin-eslint/
46
*.node

apps/oxlint/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"@types/json-schema": "^7.0.15",
4747
"@types/json-stable-stringify-without-jsonify": "^1.0.2",
4848
"@types/node": "catalog:",
49+
"@types/serialize-javascript": "^5.0.4",
4950
"@typescript-eslint/parser": "^8.54.0",
5051
"@typescript-eslint/scope-manager": "^8.54.0",
5152
"ajv": "6.14.0",
@@ -58,6 +59,7 @@
5859
"json-stable-stringify-without-jsonify": "^1.0.1",
5960
"oxc-parser": "^0.117.0",
6061
"rolldown": "catalog:",
62+
"serialize-javascript": "^7.0.4",
6163
"tsdown": "catalog:",
6264
"tsx": "^4.21.0",
6365
"type-fest": "^5.2.0",

apps/oxlint/scripts/build.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,26 @@
33
import { execSync } from "node:child_process";
44
import { copyFileSync, readdirSync, rmSync } from "node:fs";
55
import { join } from "node:path";
6+
import generatePluginEslint from "./generate-plugin-eslint.ts";
67

78
const oxlintDirPath = join(import.meta.dirname, ".."),
89
srcDirPath = join(oxlintDirPath, "src-js"),
910
distDirPath = join(oxlintDirPath, "dist"),
10-
distPkgPluginsDirPath = join(oxlintDirPath, "dist-pkg-plugins");
11+
distPkgPluginsDirPath = join(oxlintDirPath, "dist-pkg-plugins"),
12+
distPkgPluginEslintDirPath = join(oxlintDirPath, "dist-pkg-plugin-eslint");
1113

1214
// Delete `dist-pkg-plugins` directory
1315
console.log("Deleting `dist-pkg-plugins` directory...");
1416
rmSync(distPkgPluginsDirPath, { recursive: true, force: true });
1517

18+
// Delete `dist-pkg-plugin-eslint` directory
19+
console.log("Deleting `dist-pkg-plugin-eslint` directory...");
20+
rmSync(distPkgPluginEslintDirPath, { recursive: true, force: true });
21+
22+
// Generate plugin-eslint files
23+
console.log("Generating oxlint-plugin-eslint files...");
24+
generatePluginEslint();
25+
1626
// Build with tsdown
1727
console.log("Building with tsdown...");
1828
execSync("pnpm tsdown", { stdio: "inherit", cwd: oxlintDirPath });
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
/**
2+
* Generates the `oxlint-plugin-eslint` package source files.
3+
*
4+
* This script produces:
5+
*
6+
* 1. `rules/<name>.cjs` - One file for each ESLint core rule, that re-exports the rule's `create` function.
7+
* 2. `index.ts` - Exports all rules as a `Record<string, CreateRule>`.
8+
* This is the `rules` property of the `oxlint-plugin-eslint` plugin.
9+
* 3. `rule_names.ts` - Exports a list of all rule names, which is used in TSDown config.
10+
*
11+
* `index.ts` uses a split eager/lazy strategy so that `registerPlugin` can read each rule's `meta`
12+
* without loading the rule module itself:
13+
*
14+
* - `meta` is serialized and inlined at build time.
15+
* `registerPlugin` needs it at plugin registration time (for `fixable`, `hasSuggestions`, `schema`,
16+
* `defaultOptions`, `messages`), so it must be available immediately without requiring the rule module.
17+
*
18+
* - `create` is deferred via a cached `require` call.
19+
* The rule module is only loaded the first time `create` is called (i.e. when the rule actually runs at lint time).
20+
* A top-level variable per rule caches the loaded function so subsequent calls skip the `require` call.
21+
*
22+
* Build-time validations:
23+
* - Each rule object must only have `meta` and `create` properties.
24+
* - `meta` values are walked to ensure they contain no functions
25+
* (which would be serialized as executable code by `serialize-javascript`).
26+
*/
27+
28+
import { readdirSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
29+
import { join as pathJoin, basename, relative as pathRelative } from "node:path";
30+
import { createRequire } from "node:module";
31+
import { execFileSync } from "node:child_process";
32+
import serialize from "serialize-javascript";
33+
34+
import type { CreateRule } from "../src-js/plugins/load.ts";
35+
import type { RuleMeta } from "../src-js/plugins/rule_meta.ts";
36+
37+
const require = createRequire(import.meta.url);
38+
39+
const oxlintDirPath = pathJoin(import.meta.dirname, "..");
40+
const rootDirPath = pathJoin(oxlintDirPath, "../..");
41+
const eslintRulesDir = pathJoin(require.resolve("eslint/package.json"), "../lib/rules");
42+
const generatedDirPath = pathJoin(oxlintDirPath, "src-js/generated/plugin-eslint");
43+
const generatedRulesDirPath = pathJoin(generatedDirPath, "rules");
44+
45+
export default function generatePluginEslint(): void {
46+
// Get all ESLint rule names (exclude `index.js` which is the registry, not a rule)
47+
const ruleNames = readdirSync(eslintRulesDir)
48+
.filter((filename) => filename.endsWith(".js") && filename !== "index.js")
49+
.map((filename) => basename(filename, ".js"))
50+
.sort();
51+
52+
// oxlint-disable-next-line no-console
53+
console.log(`Found ${ruleNames.length} ESLint rules`);
54+
55+
// Wipe and recreate generated directories
56+
rmSync(generatedDirPath, { recursive: true, force: true });
57+
mkdirSync(generatedRulesDirPath, { recursive: true });
58+
59+
// Generate a CJS wrapper file for each rule
60+
for (const ruleName of ruleNames) {
61+
const relPath = pathRelative(generatedRulesDirPath, pathJoin(eslintRulesDir, `${ruleName}.js`));
62+
const content = `module.exports = require(${JSON.stringify(relPath)}).create;\n`;
63+
writeFileSync(pathJoin(generatedRulesDirPath, `${ruleName}.cjs`), content);
64+
}
65+
66+
// Generate the plugin rules index.
67+
// `meta` is inlined so it's available at registration time without loading the rule module.
68+
// `create` is deferred via a cached `require` so the rule module is only loaded on first use.
69+
const indexLines = [
70+
`
71+
import { createRequire } from "node:module";
72+
73+
import type { CreateRule } from "../../plugins/load.ts";
74+
75+
type CreateFn = CreateRule["create"];
76+
77+
var require = createRequire(import.meta.url);
78+
`,
79+
];
80+
81+
// Generate a `let` declaration for each rule's cached `create` function.
82+
// These are initially `null` and populated on first call.
83+
for (let i = 0; i < ruleNames.length; i++) {
84+
indexLines.push(`var create${i}: CreateFn | null = null;`);
85+
}
86+
87+
indexLines.push("", "export default {");
88+
89+
for (let i = 0; i < ruleNames.length; i++) {
90+
const ruleName = ruleNames[i];
91+
const rulePath = pathJoin(eslintRulesDir, `${ruleName}.js`);
92+
const rule: CreateRule = require(rulePath);
93+
94+
// Validate that the rule only has expected top-level properties.
95+
// If ESLint adds new properties in a future version, we want to find out at build time.
96+
const unexpectedKeys = Object.keys(rule).filter((key) => key !== "meta" && key !== "create");
97+
if (unexpectedKeys.length > 0) {
98+
throw new Error(
99+
`Unexpected properties on rule \`${ruleName}\`: ${unexpectedKeys.join(", ")}. ` +
100+
"Expected only `meta` and `create`.",
101+
);
102+
}
103+
104+
// Reduce `meta` to only the properties Oxlint uses, with consistent shape and property order.
105+
// We discard e.g. `deprecated` and `docs` properties. This reduces code size.
106+
// Default values match what `registerPlugin` assumes when a property is absent.
107+
const { meta } = rule;
108+
const reducedMeta: RuleMeta = {
109+
messages: meta?.messages ?? undefined,
110+
fixable: meta?.fixable ?? null,
111+
hasSuggestions: meta?.hasSuggestions ?? false,
112+
schema: meta?.schema ?? undefined,
113+
defaultOptions: meta?.defaultOptions ?? undefined,
114+
};
115+
116+
// Check for function values in `reducedMeta`, which would be unexpected and likely a bug.
117+
// `serialize-javascript` would serialize them as executable code, so catch this at build time.
118+
assertNoFunctions(reducedMeta, `eslint/lib/rules/${ruleName}.js`, "meta");
119+
120+
const metaCode = serialize(reducedMeta, { unsafe: true });
121+
122+
indexLines.push(`
123+
${JSON.stringify(ruleName)}: {
124+
meta: ${metaCode},
125+
create(context) {
126+
if (create${i} === null) create${i} = require("./rules/${ruleName}.cjs") as CreateFn;
127+
return create${i}(context);
128+
},
129+
},
130+
`);
131+
}
132+
indexLines.push("} satisfies Record<string, CreateRule>;\n");
133+
134+
const indexFilePath = pathJoin(generatedDirPath, "index.ts");
135+
writeFileSync(indexFilePath, indexLines.join("\n"));
136+
137+
// Format generated index file with oxfmt to clean up unnecessary quotes around property names.
138+
// This isn't necessary, as it gets minified and bundled anyway, but it makes generated code easier to read
139+
// when debugging.
140+
execFileSync("pnpm", ["exec", "oxfmt", "--write", indexFilePath], { cwd: rootDirPath });
141+
142+
// Generate the rule_names.ts file for use in tsdown config
143+
const ruleNamesCode = [
144+
"export default [",
145+
...ruleNames.map((name) => ` ${JSON.stringify(name)},`),
146+
"] as const;\n",
147+
].join("\n");
148+
149+
writeFileSync(pathJoin(generatedDirPath, "rule_names.ts"), ruleNamesCode);
150+
151+
// oxlint-disable-next-line no-console
152+
console.log("Generated plugin-eslint files.");
153+
}
154+
155+
/**
156+
* Walk an object tree and throw if any function values are found.
157+
*/
158+
function assertNoFunctions(value: unknown, rulePath: string, path: string): void {
159+
if (typeof value === "function") {
160+
throw new Error(
161+
`Unexpected function value in \`${path}\` of rule \`${rulePath}\`. ` +
162+
"Rule meta objects must be static data.",
163+
);
164+
}
165+
if (typeof value === "object" && value !== null) {
166+
for (const [key, child] of Object.entries(value)) {
167+
assertNoFunctions(child, rulePath, `${path}.${key}`);
168+
}
169+
}
170+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// oxlint-disable-next-line typescript/ban-ts-comment
2+
// @ts-ignore - file is generated and not checked in to git
3+
import rules from "../generated/plugin-eslint/index.ts";
4+
5+
export default {
6+
meta: {
7+
name: "eslint-js",
8+
},
9+
rules,
10+
};
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"categories": {
3+
"correctness": "off"
4+
},
5+
"jsPlugins": ["../../../dist-pkg-plugin-eslint/index.js"],
6+
"rules": {
7+
"eslint-js/array-bracket-newline": ["error", "consistent"],
8+
"eslint-js/no-restricted-syntax": [
9+
"error",
10+
{
11+
"selector": "ThrowStatement > CallExpression[callee.name=/Error$/]",
12+
"message": "Use `new` keyword when throwing an `Error`."
13+
}
14+
]
15+
}
16+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// Violation: array-bracket-newline (opening has newline, closing does not)
2+
const a = [
3+
1, 2, 3];
4+
5+
// Violation: no-restricted-syntax (throw Error without `new`)
6+
throw TypeError("bad");
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Exit code
2+
1
3+
4+
# stdout
5+
```
6+
x eslint-js(array-bracket-newline): A linebreak is required before ']'.
7+
,-[files/index.js:3:10]
8+
2 | const a = [
9+
3 | 1, 2, 3];
10+
: ^
11+
4 |
12+
`----
13+
14+
x eslint-js(no-restricted-syntax): Use `new` keyword when throwing an `Error`.
15+
,-[files/index.js:6:7]
16+
5 | // Violation: no-restricted-syntax (throw Error without `new`)
17+
6 | throw TypeError("bad");
18+
: ^^^^^^^^^^^^^^^^
19+
`----
20+
21+
Found 0 warnings and 2 errors.
22+
Finished in Xms on 1 file with 2 rules using X threads.
23+
```
24+
25+
# stderr
26+
```
27+
```

apps/oxlint/tsdown.config.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import fs from "node:fs";
22
import { join as pathJoin, relative as pathRelative, dirname } from "node:path";
33
import { defineConfig } from "tsdown";
44
import { parseSync, Visitor } from "oxc-parser";
5+
// oxlint-disable-next-line typescript/ban-ts-comment
6+
// @ts-ignore - file is generated and not checked in to git
7+
import ruleNames from "./src-js/generated/plugin-eslint/rule_names.ts";
58

69
import type { Plugin } from "rolldown";
710

@@ -65,6 +68,25 @@ const pluginsPkgConfig = defineConfig({
6568
define: definedGlobals,
6669
});
6770

71+
// Base config for `oxlint-plugin-eslint` package
72+
const pluginEslintPkgConfig = defineConfig({
73+
...commonConfig,
74+
outDir: "dist-pkg-plugin-eslint",
75+
minify: minifyConfig,
76+
// `build.ts` deletes the directory before TSDown runs.
77+
// This allows generating the ESM and CommonJS builds in the same directory.
78+
clean: false,
79+
dts: false,
80+
});
81+
82+
// Build entries for `oxlint-plugin-eslint` rule files.
83+
// Each rule is a separate CJS file, lazy-loaded on demand.
84+
const pluginEslintRulesEntries: Record<string, string> = {};
85+
for (const ruleName of ruleNames) {
86+
pluginEslintRulesEntries[`rules/${ruleName}`] =
87+
`src-js/generated/plugin-eslint/rules/${ruleName}.cjs`;
88+
}
89+
6890
// Plugins.
6991
// Only remove debug assertions in release build.
7092
const plugins = [createReplaceGlobalsPlugin()];
@@ -108,6 +130,18 @@ export default defineConfig([
108130
format: "commonjs",
109131
dts: false,
110132
},
133+
134+
// `oxlint-plugin-eslint` package
135+
{
136+
...pluginEslintPkgConfig,
137+
entry: { index: "src-js/plugin-eslint/index.ts" },
138+
format: "esm",
139+
},
140+
{
141+
...pluginEslintPkgConfig,
142+
entry: pluginEslintRulesEntries,
143+
format: "commonjs",
144+
},
111145
]);
112146

113147
/**

0 commit comments

Comments
 (0)