Skip to content

Latest commit

 

History

History
463 lines (386 loc) · 20.5 KB

File metadata and controls

463 lines (386 loc) · 20.5 KB

Oxlint Support

@isentinel/eslint-config supports oxlint, a high-performance Rust linter, in three consumption modes:

  1. ESLint only (default) — nothing changes for existing consumers.
  2. Hybrid — oxlint runs the rules it covers (natively, via oxlint-tsgolint for type-aware rules, or by running the original ESLint plugins inside oxlint as jsPlugins); ESLint runs the rest.
  3. Oxlint standalone — an oxlint-only config via the @isentinel/eslint-config/oxlint export.

Installation

Oxlint support requires the optional peer dependencies:

pnpm i -D oxlint oxlint-tsgolint

Hybrid mode (recommended)

Enable oxlint: true in your ESLint config and create an oxlint config using the same options:

// eslint.config.ts
import isentinel from "@isentinel/eslint-config";

export default isentinel({
	oxlint: true,
	type: "game",
});
// oxlint.config.ts
import { isentinel } from "@isentinel/eslint-config/oxlint";

export default isentinel({
	name: "project/options",
	type: "game",
});

[!NOTE] In hybrid mode, give the oxlint factory the same structural and plugin options you gave ESLint (type, roblox, stylistic, root, react, test, ignores...). The react and jest families run in oxlint as jsPlugins and vitest runs as native oxlint rules (bar two rules with no native port), while ESLint keeps only their type-aware rules, so mirroring the options avoids both coverage loss and double-linting. e18e is handled automatically (both factories enable it for non-roblox projects).

Then run both linters:

// package.json
{
	"scripts": {
		"lint": "oxlint && eslint --cache",
	},
}

[!IMPORTANT] The type-aware ts/* rules are executed by oxlint-tsgolint. The factory enables options.typeAware in the generated config by default (when oxlint-tsgolint is resolvable), so no --type-aware CLI flag is needed. If oxlint-tsgolint is not installed, the ESLint factory keeps these rules in ESLint instead of dropping them (and warns at config-build time), so they run in exactly one engine either way.

Residual edge: if you have oxlint-tsgolint installed but explicitly set options: { typeAware: false } in your oxlint config, those rules run in neither engine — the ESLint factory cannot see your oxlint config, so it still drops them. Disabling typeAware in hybrid mode is on you. Likewise, when ESLint and oxlint run from different working directories (per-package monorepo linting), their two oxlint-tsgolint resolutions can disagree; keep the peer resolvable from every lint cwd (or set options.typeAware explicitly) to avoid double-reporting or one-sided coverage.

How rules are split

The split is driven by one internal resolver backed by generated native and jsPlugin capability metadata. oxlintRuleMapping, exported from @isentinel/eslint-config/oxlint, remains as a generated compatibility and audit view:

Target Meaning
native Implemented natively in oxlint (Rust)
tsgolint Type-aware rule executed by oxlint-tsgolint (typeAware)
js-plugin The original ESLint plugin runs inside oxlint as a jsPlugin
stays in ESLint Not in the mapping; documented in staysInEslint

A rule you name in options.rules is resolved in this order: an alternate native equivalent, an explicit ESLint-only exception, an implementation preference, a generated native capability, then a safe generated jsPlugin capability. Unknown custom rules retain the standalone fallback behavior. Examples include ts/no-shadowno-shadow, jest/valid-titlejest-js/valid-title, and unmapped native rules such as no-inner-declarations staying native. Preferring the native rule wherever one exists is what keeps a categories entry overridable: categories only ever enable native rules, so an entry routed to a jsPlugin instead would configure a different rule and leave the native one on its defaults.

Jest and React prefer their jsPlugins; Vitest and Unicorn prefer native implementations. id-length, no-loop-func, and unicorn/no-lonely-if are exact jsPlugin preferences, the last because its JS implementation currently has better autofix parity. Whenever a preferred jsPlugin also has a native implementation, the generated config disables the native rule at the same scope so categories cannot activate a duplicate. This suppression remains in native-only mode even though the JS rule itself stays in ESLint.

The generated capability data in src/generated/ is rule names only: which rules oxlint implements natively (from oxlint --rules), which rules the installed plugins expose, and which of each need type information. Everything else a rule carries — categories, fix status, documentation links — lives in src/oxlint/typegen.d.ts as JSDoc, where an editor can show it, rather than shipping as runtime data. Oxlint names, plugin aliases and package specifiers are derived from the rule name at lookup time. pnpm gen refreshes all of it, so a dependency upgrade produces a reviewable diff of what changed hands.

A preset-only "off" entry does not load a jsPlugin merely because a capability exists. It is still emitted when the rule is native: categories can only ever switch native rules back on, so a rule the preset deliberately disables has to reach the config as an explicit "off" or opting into a category would silently revive it. Explicit user disables remain authoritative in both cases.

Fragments passed as extra arguments are not translated — their rule names reach Oxlint verbatim, which is the escape hatch when naming an Oxlint rule directly.

With oxlint: true, the ESLint factory drops every rule the resolver hands to oxlint — not every rule in the exported view, which is a sample of the preset and would leave a rule enabled in both engines if it missed one. Everything else keeps running in ESLint, notably:

  • JSON / YAML / TOML / Markdown / package.json / pnpm rules — oxlint only lints JS/TS files.
  • Type-aware plugin rules — any rule whose meta.docs.requiresTypeChecking is true (sonar/no-ignored-return, sonar/no-redundant-optional, sonar/no-try-promise, unicorn/no-non-function-verb-prefix, eslint-plugin/no-property-in-node, four jest/* rules). The oxlint factory refuses to emit them as jsPlugins because they crash or silently no-op without type information; a test enforces this against the plugins' runtime metadata.
  • Functionally type-aware rules — the type-aware React rules below do not declare requiresTypeChecking but still need type information, so they are excluded manually. The jest family runs in oxlint via its real ESLint plugin as a jsPlugin under the jest-js prefix (oxlint reserves the native jest prefix; jest honors settings.jest.globalPackage = @rbxts/jest-globals); the vitest family runs as native oxlint rules, except vitest/padding-around-all and vitest/prefer-vi-mocked (no native port) which run via @vitest/eslint-plugin as a jsPlugin.
  • Type-aware custom rulesroblox/* type-aware rules, sentinel/explicit-size-check, flawless/prefer-read-only-props, flawless/naming-convention and naming; oxlint jsPlugins have no type information.
  • Type-aware React rulesreact/no-implicit-children, react/no-implicit-key, react/no-implicit-ref, react/no-leaked-conditional-rendering and react/no-unused-props need type information but do not declare requiresTypeChecking, so they are excluded manually. The rest of the react family (react-x, react-jsx, react-naming-convention, the flawless react syntax rules) runs in oxlint as jsPlugins.
  • eslint-comments/* — these lint eslint-disable directives; oxlint-comments/* covers the oxlint directives on the oxlint side.
  • Markdown code blocks — rules and formatting for fenced code blocks stay in ESLint (oxlint cannot lint virtual files).

A test suite asserts that every rule dropped from ESLint in hybrid mode is enabled in the oxlint factory output, so coverage loss is a test failure.

Oxlint-only rules

Traffic also flows the other way: oxlint's native oxc/* rules and, under react: true, its react-perf/* rules have no ESLint counterpart in the preset, so they run in oxlint alone.

The four react-perf rules — jsx-no-new-object-as-prop, jsx-no-new-array-as-prop, jsx-no-new-function-as-prop and jsx-no-jsx-as-prop — flag values built inline in JSX, which get a fresh identity every render and defeat React.memo and hook-dependency bailouts. They are enabled as errors. With roblox: true (the default) they are configured with nativeAllowList: "all", which skips host tags: <textbutton Event={{ … }} /> is idiomatic Roblox UI with no reasonable way out. Under roblox: false the rules apply to host elements too.

The rules match object literals, array literals, function expressions and JSX — not call expressions, so <frame Size={UDim2.fromScale(1, 1)} /> is not reported even though it churns the same way.

Native-only hybrid (experimental)

oxlint: "native" narrows the hand-off: oxlint runs only the rules it implements in Rust (native rules plus the tsgolint type-aware ones). Everything a jsPlugin would have run — spelling, sonar/*, the non-native unicorn/*, perfectionist/*, the react and jest families, the custom roblox/* and flawless/* rules and oxfmt formatting — stays in ESLint, where the original plugins already run.

oxlint-comments is the one jsPlugin that stays loaded. Native-only does not remove oxlint-disable comments, only narrows which rules they suppress, so the directives still need linting — and ESLint cannot take the plugin over, because its rules use oxlint's createOnce API (ESLint rejects a rule without create). Keeping it is close to free: its rules visit Program once per file, and the cost that native-only mode exists to avoid is the volume of jsPlugin rules, not the JS bridge itself. Measured on this repo (src test scripts, best of 5, with tsgolint): 2139 ms with no jsPlugins, 2367 ms with oxlint-comments, 14426 ms with the full jsPlugin set.

Both sides must agree, or rules run twice (or nowhere):

// eslint.config.ts
export default isentinel({ oxlint: "native", type: "game" });
// oxlint.config.ts
export default isentinel({
	name: "project/options",
	jsPlugins: false,
	type: "game",
});

With isentinel-lint this yields three passes, each with a distinct job:

Pass Engine Runs
oxc oxlint (+ tsgolint) native Rust rules, type-aware ts/*
fast ESLint, no types everything else that needs no type information
typed ESLint, type-aware the remaining type-aware rules (roblox/*, flawless/naming-convention, the type-aware react and sonar rules)

Trade-offs versus full hybrid: the jsPlugin rules run under ESLint's slower runtime again, but they run under their real plugin (no oxlint jsPlugin scope or metadata caveats), and formatting no longer goes through the oxfmt jsPlugin.

Rules you configure in your own oxlint.config.ts move with them: once jsPlugins: false is set, an entry naming a plugin that is no longer loaded ("sonar/*", say) is dropped from the generated config, since oxlint rejects the whole build over a rule whose plugin is not registered. Put those entries in eslint.config.ts, which is where the rules now run. JsPlugins you register yourself are untouched, and so are their rules — but the entry's name is what the rule prefix is keyed on, so register with the exported resolver rather than a bare specifier, or the rules silently go with the drop:

import {
	isentinel,
	resolveJsPluginSpecifier,
} from "@isentinel/eslint-config/oxlint";

export default isentinel(
	{ name: "project/options", jsPlugins: false },
	{
		name: "local/my-plugin",
		files: ["**/*.ts"],
		jsPlugins: [
			{ name: "my", specifier: resolveJsPluginSpecifier("eslint-plugin-my") },
		],
		rules: { "my/some-rule": "error" },
	},
);

Dead rule warning

Because the ESLint side drops every oxlint-owned rule (and lets oxlint format real JS/TS files), any rule in your own config that oxlint owns has no effect in ESLint. Setting "oxfmt/oxfmt": "off" in a scoped ESLint block, for example, does not stop oxlint from formatting the file.

In hybrid mode the factory warns at config-build time and lists each such entry (rule name and config block), telling you to move it to oxlint.config.ts (or your oxfmt options) or remove it. Entries scoped to Markdown virtual files (**/*.md/**) are exempt, since oxlint cannot lint them. Suppress the warning with oxlintWarnDeadRules: false.

The check treats every config not named isentinel/* as yours, so a third-party shareable config you compose in can trigger the warning too (its oxlint-owned entries are genuinely inert in hybrid mode). Suppress it with oxlintWarnDeadRules: false if you cannot edit that config.

Rule overrides

The overrides on a config option applies at the scope that option already covers, and outranks the preset there — whether it enables or disables, and whether or not the preset ships the rule at all:

export default isentinel({
	name: "project/options",
	test: {
		vitest: {
			files: ["**/*.spec.ts"],
			overrides: {
				"vitest/no-importing-vitest-globals": "off",
				// Not enabled by the preset; on for spec files only.
				"vitest/require-hook": "error",
			},
		},
	},
	type: "package",
});

Two things still bound what an override can do:

  • Position. An override sits where its config option sits in the pipeline, so a later module that names the same rule wins. Disabling an oxc/* rule from typescript.overrides is undone by the oxc module that follows it; use the top-level rules, which merges after every preset module.
  • Reachability. A few rules cannot run in oxlint: those needing type information no jsPlugin can supply, those whose plugin is not installed, and — under jsPlugins: false — every jsPlugin rule. The factory warns and lists them at config-build time rather than discarding them quietly; the ESLint side still runs them. Suppress with warnDroppedOverrides: false.

Standalone mode

Use only oxlint (no ESLint) via the dedicated export:

// oxlint.config.ts
import { isentinel } from "@isentinel/eslint-config/oxlint";

export default isentinel({
	name: "project/options",
	test: { jest: true },
	type: "game",
});

Standalone mode keeps spell checking (CSpell), directive-comment hygiene (oxlint-comments), formatting (oxfmt) and the custom roblox-ts rules by running the ESLint plugins as jsPlugins. Note the limitations above: JSON, YAML, TOML, Markdown, pnpm and the type-aware custom rules are only available through ESLint.

Categories, linter options, env and globals

The factory passes oxlint's config-level categories, options, env and globals through to the generated config:

// oxlint.config.ts
import { isentinel } from "@isentinel/eslint-config/oxlint";

export default isentinel({
	name: "project/options",
	categories: { nursery: "warn" },
	env: { browser: false },
	globals: { $: "readonly", window: "off" },
	options: { maxWarnings: 10 },
	type: "game",
});
  • categories — enables oxlint rule categories (correctness, nursery, pedantic, perf, restriction, style, suspicious). The preset enables every rule it wants explicitly, so all seven categories default to "off" — otherwise oxlint's own defaults (notably correctness: "warn") would fire on top of the curated set and report rules the preset deliberately leaves out. Values are merged over that default, so enabling one category leaves the rest off. Useful for previewing rules the preset does not enable yet, but expect reports the preset has not vetted. A category rule is silenced or reconfigured from options.rules under its oxlint name — see How rules are split for how a name resolves.

    A category only reaches plugins registered at the top level: oxlint applies categories to the base plugin set, not to plugins an override adds. The preset keeps a plugin at the top level when it asks for it across the whole tree (typescript, unicorn, import, jsdoc, promise, oxc, plus react-perf under react: true), so categories reach those project-wide. File-scoped ones — vitest under the test globs, node over the complement of the roblox globs — are registered on their own override instead, which keeps them from firing outside those globs; inside them they contribute the rules the preset curates, and categories add nothing further.

    To opt a plugin into your categories project-wide, register it yourself on a whole-tree config fragment:

    isentinel(
    	{ categories: { correctness: "error" } },
    	{
    		name: "project/native-plugins",
    		files: [GLOB_SRC],
    		plugins: ["jsx-a11y"],
    		rules: {},
    	},
    );
  • options — top-level linter options (typeAware, typeCheck, maxWarnings, denyWarnings, reportUnusedDisableDirectives, respectEslintDisableDirectives). The factory emits typeAware: true by default whenever oxlint-tsgolint is resolvable (oxlint errors when typeAware is enabled without it), so type-aware rules run without any CLI flag; user-provided values win. CLI flags take precedence over config options when both are set.

  • env — enables or disables predefined environment globals (browser, node, es2021...). Emitted at the top level.

  • globals — declares project globals as "readonly", "writable" or "off". Values are emitted at the top level and as a trailing override for source files, so they win over the globals the preset declares (browser, node and ES2021). Note that oxlint does not let "off" remove globals that come from env or the ES builtins — it only removes globals declared at an outer config level.

Migrating disable comments

In hybrid mode, // eslint-disable-next-line <rule> comments for rules that moved to oxlint become unused directives (ESLint reports them) and must be converted to // oxlint-disable-next-line <rule> using the oxlint-side rule name:

  • Core rules keep their name (no-console).
  • ts/<rule> becomes typescript/<rule>.
  • Rules that run as jsPlugins use the aliased prefix from the mapping (unicorn/* that is not native becomes unicorn-js/*, core rules that are not native become eslint-js/*).

The official @oxlint/migrate tool can convert comments in bulk with --replace-eslint-comments.

Known issue: intermittent segfault formatting Markdown

Linting a Markdown file occasionally (roughly 1 in 15 runs) crashes node with a segmentation fault (exit 139 / 0xC0000005). This is independent of oxlint and hybrid mode — it reproduces identically with the published ESLint-only config (6.0.0-beta.6) and disappears when oxfmt/oxfmt is disabled for **/*.md — the crash is in eslint-plugin-oxfmt's synckit worker invoking the oxfmt native binding on Markdown files (the oxfmt CLI and the in-process format() API do not crash on the same input). Until fixed upstream, rerun the lint on a crash; disabling Markdown formatting (formatters: { markdown: false }) avoids it at the cost of unformatted Markdown.

ESLint 10 peer note

If you are on ESLint 10, @typescript-eslint/utils and @typescript-eslint/type-utils must resolve to >=8.62.1 or ESLint crashes at startup (Class extends value undefined). This repo pins them via pnpm overrides, which do not travel with the published package — add the same overrides to your own pnpm-workspace.yaml if you hit that error:

overrides:
  "@typescript-eslint/type-utils": 8.62.1
  "@typescript-eslint/utils": 8.62.1

Validating the config

The repo ships scripts/validate-oxlint.ts, which checks every native rule in the generated config against the installed oxlint binary (oxlint --rules):

pnpm validate:oxlint