Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/release_apps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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: |
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions apps/oxlint/.gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
/node_modules/
/dist/
/dist-pkg-plugins/
/dist-pkg-plugin-eslint/
/src-js/generated/plugin-eslint/
*.node
2 changes: 2 additions & 0 deletions apps/oxlint/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
12 changes: 11 additions & 1 deletion apps/oxlint/scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
170 changes: 170 additions & 0 deletions apps/oxlint/scripts/generate-plugin-eslint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/**
* Generates the `oxlint-plugin-eslint` package source files.
*
* This script produces:
*
* 1. `rules/<name>.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<string, CreateRule>`.
* 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<string, CreateRule>;\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}`);
}
}
}
10 changes: 10 additions & 0 deletions apps/oxlint/src-js/plugin-eslint/index.ts
Original file line number Diff line number Diff line change
@@ -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,
};
16 changes: 16 additions & 0 deletions apps/oxlint/test/fixtures/plugin_eslint/.oxlintrc.json
Original file line number Diff line number Diff line change
@@ -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`."
}
]
}
}
6 changes: 6 additions & 0 deletions apps/oxlint/test/fixtures/plugin_eslint/files/index.js
Original file line number Diff line number Diff line change
@@ -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");
27 changes: 27 additions & 0 deletions apps/oxlint/test/fixtures/plugin_eslint/output.snap.md
Original file line number Diff line number Diff line change
@@ -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
```
```
34 changes: 34 additions & 0 deletions apps/oxlint/tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<string, string> = {};
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()];
Expand Down Expand Up @@ -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",
},
]);

/**
Expand Down
3 changes: 3 additions & 0 deletions npm/oxlint-plugin-eslint/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Changelog

All notable changes to this package will be documented in this file.
Loading
Loading